feat(cron): introduce delivery modes for isolated jobs
- Added support for new delivery modes in cron jobs: `announce`, `deliver`, and `none`. - Updated documentation to reflect changes in delivery options and usage examples. - Enhanced the cron job schema to include delivery configuration. - Refactored related CLI commands and UI components to accommodate the new delivery settings. - Improved handling of legacy delivery fields for backward compatibility. This update allows users to choose how output from isolated jobs is delivered, enhancing flexibility in job management.main
parent
3a03e38378
commit
511c656cbc
|
|
@ -45,6 +45,7 @@ Docs: https://docs.openclaw.ai
|
||||||
|
|
||||||
- Feishu: add Feishu/Lark plugin support + docs. (#7313) Thanks @jiulingyun (openclaw-cn).
|
- Feishu: add Feishu/Lark plugin support + docs. (#7313) Thanks @jiulingyun (openclaw-cn).
|
||||||
- Web UI: add Agents dashboard for managing agent files, tools, skills, models, channels, and cron jobs.
|
- Web UI: add Agents dashboard for managing agent files, tools, skills, models, channels, and cron jobs.
|
||||||
|
- Cron: add announce delivery mode for isolated jobs (CLI + Control UI) and delivery mode config.
|
||||||
- Memory: implement the opt-in QMD backend for workspace memory. (#3160) Thanks @vignesh07.
|
- Memory: implement the opt-in QMD backend for workspace memory. (#3160) Thanks @vignesh07.
|
||||||
- Security: add healthcheck skill and bootstrap audit guidance. (#7641) Thanks @Takhoffman.
|
- Security: add healthcheck skill and bootstrap audit guidance. (#7641) Thanks @Takhoffman.
|
||||||
- Config: allow setting a default subagent thinking level via `agents.defaults.subagents.thinking` (and per-agent `agents.list[].subagents.thinking`). (#7372) Thanks @tyler6204.
|
- Config: allow setting a default subagent thinking level via `agents.defaults.subagents.thinking` (and per-agent `agents.list[].subagents.thinking`). (#7372) Thanks @tyler6204.
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ cron is the mechanism.
|
||||||
- Jobs persist under `~/.openclaw/cron/` so restarts don’t lose schedules.
|
- Jobs persist under `~/.openclaw/cron/` so restarts don’t lose schedules.
|
||||||
- Two execution styles:
|
- Two execution styles:
|
||||||
- **Main session**: enqueue a system event, then run on the next heartbeat.
|
- **Main session**: enqueue a system event, then run on the next heartbeat.
|
||||||
- **Isolated**: run a dedicated agent turn in `cron:<jobId>`, optionally deliver output.
|
- **Isolated**: run a dedicated agent turn in `cron:<jobId>`, with a delivery mode (legacy summary, announce, full output, or none).
|
||||||
- Wakeups are first-class: a job can request “wake now” vs “next heartbeat”.
|
- Wakeups are first-class: a job can request “wake now” vs “next heartbeat”.
|
||||||
|
|
||||||
## Quick start (actionable)
|
## Quick start (actionable)
|
||||||
|
|
@ -53,7 +53,7 @@ openclaw cron add \
|
||||||
--tz "America/Los_Angeles" \
|
--tz "America/Los_Angeles" \
|
||||||
--session isolated \
|
--session isolated \
|
||||||
--message "Summarize overnight updates." \
|
--message "Summarize overnight updates." \
|
||||||
--deliver \
|
--announce \
|
||||||
--channel slack \
|
--channel slack \
|
||||||
--to "channel:C1234567890"
|
--to "channel:C1234567890"
|
||||||
```
|
```
|
||||||
|
|
@ -96,7 +96,7 @@ A cron job is a stored record with:
|
||||||
|
|
||||||
- a **schedule** (when it should run),
|
- a **schedule** (when it should run),
|
||||||
- a **payload** (what it should do),
|
- a **payload** (what it should do),
|
||||||
- optional **delivery** (where output should be sent).
|
- optional **delivery mode** (announce, full output, or none).
|
||||||
- optional **agent binding** (`agentId`): run the job under a specific agent; if
|
- optional **agent binding** (`agentId`): run the job under a specific agent; if
|
||||||
missing or unknown, the gateway falls back to the default agent.
|
missing or unknown, the gateway falls back to the default agent.
|
||||||
|
|
||||||
|
|
@ -136,9 +136,12 @@ Key behaviors:
|
||||||
|
|
||||||
- Prompt is prefixed with `[cron:<jobId> <job name>]` for traceability.
|
- Prompt is prefixed with `[cron:<jobId> <job name>]` for traceability.
|
||||||
- Each run starts a **fresh session id** (no prior conversation carry-over).
|
- Each run starts a **fresh session id** (no prior conversation carry-over).
|
||||||
- A summary is posted to the main session (prefix `Cron`, configurable).
|
- Legacy behavior (no `delivery` field): a summary is posted to the main session (prefix `Cron`, configurable).
|
||||||
- `wakeMode: "now"` triggers an immediate heartbeat after posting the summary.
|
- `delivery.mode` (isolated-only) chooses what happens instead of the legacy summary:
|
||||||
- If `payload.deliver: true`, output is delivered to a channel; otherwise it stays internal.
|
- `announce`: subagent-style summary delivered immediately to a chat.
|
||||||
|
- `deliver`: full agent output delivered immediately to a chat.
|
||||||
|
- `none`: internal only (no main summary, no delivery).
|
||||||
|
- `wakeMode: "now"` triggers an immediate heartbeat after posting the **legacy** summary.
|
||||||
|
|
||||||
Use isolated jobs for noisy, frequent, or "background chores" that shouldn't spam
|
Use isolated jobs for noisy, frequent, or "background chores" that shouldn't spam
|
||||||
your main chat history.
|
your main chat history.
|
||||||
|
|
@ -155,10 +158,20 @@ Common `agentTurn` fields:
|
||||||
- `message`: required text prompt.
|
- `message`: required text prompt.
|
||||||
- `model` / `thinking`: optional overrides (see below).
|
- `model` / `thinking`: optional overrides (see below).
|
||||||
- `timeoutSeconds`: optional timeout override.
|
- `timeoutSeconds`: optional timeout override.
|
||||||
- `deliver`: `true` to send output to a channel target.
|
|
||||||
- `channel`: `last` or a specific channel.
|
Delivery config (isolated jobs only):
|
||||||
- `to`: channel-specific target (phone/chat/channel id).
|
|
||||||
- `bestEffortDeliver`: avoid failing the job if delivery fails.
|
- `delivery.mode`: `none` | `announce` | `deliver`.
|
||||||
|
- `delivery.channel`: `last` or a specific channel.
|
||||||
|
- `delivery.to`: channel-specific target (phone/chat/channel id).
|
||||||
|
- `delivery.bestEffort`: avoid failing the job if delivery fails (deliver mode).
|
||||||
|
|
||||||
|
Legacy delivery fields (still accepted when `delivery` is omitted):
|
||||||
|
|
||||||
|
- `payload.deliver`: `true` to send output to a channel target.
|
||||||
|
- `payload.channel`: `last` or a specific channel.
|
||||||
|
- `payload.to`: channel-specific target (phone/chat/channel id).
|
||||||
|
- `payload.bestEffortDeliver`: avoid failing the job if delivery fails.
|
||||||
|
|
||||||
Isolation options (only for `session=isolated`):
|
Isolation options (only for `session=isolated`):
|
||||||
|
|
||||||
|
|
@ -166,6 +179,8 @@ Isolation options (only for `session=isolated`):
|
||||||
- `postToMainMode`: `summary` (default) or `full`.
|
- `postToMainMode`: `summary` (default) or `full`.
|
||||||
- `postToMainMaxChars`: max chars when `postToMainMode=full` (default 8000).
|
- `postToMainMaxChars`: max chars when `postToMainMode=full` (default 8000).
|
||||||
|
|
||||||
|
Note: isolation post-to-main settings apply to legacy jobs (no `delivery` field). If `delivery` is set, the legacy summary is skipped.
|
||||||
|
|
||||||
### Model and thinking overrides
|
### Model and thinking overrides
|
||||||
|
|
||||||
Isolated jobs (`agentTurn`) can override the model and thinking level:
|
Isolated jobs (`agentTurn`) can override the model and thinking level:
|
||||||
|
|
@ -185,19 +200,24 @@ Resolution priority:
|
||||||
|
|
||||||
### Delivery (channel + target)
|
### Delivery (channel + target)
|
||||||
|
|
||||||
Isolated jobs can deliver output to a channel. The job payload can specify:
|
Isolated jobs can deliver output to a channel via the top-level `delivery` config:
|
||||||
|
|
||||||
- `channel`: `whatsapp` / `telegram` / `discord` / `slack` / `mattermost` (plugin) / `signal` / `imessage` / `last`
|
- `delivery.mode`: `announce` (subagent-style summary) or `deliver` (full output).
|
||||||
- `to`: channel-specific recipient target
|
- `delivery.channel`: `whatsapp` / `telegram` / `discord` / `slack` / `mattermost` (plugin) / `signal` / `imessage` / `last`.
|
||||||
|
- `delivery.to`: channel-specific recipient target.
|
||||||
|
|
||||||
If `channel` or `to` is omitted, cron can fall back to the main session’s “last route”
|
Delivery config is only valid for isolated jobs (`sessionTarget: "isolated"`).
|
||||||
(the last place the agent replied).
|
|
||||||
|
|
||||||
Delivery notes:
|
If `delivery.channel` or `delivery.to` is omitted, cron can fall back to the main session’s
|
||||||
|
“last route” (the last place the agent replied).
|
||||||
|
|
||||||
- If `to` is set, cron auto-delivers the agent’s final output even if `deliver` is omitted.
|
Legacy behavior (no `delivery` field):
|
||||||
- Use `deliver: true` when you want last-route delivery without an explicit `to`.
|
|
||||||
- Use `deliver: false` to keep output internal even if a `to` is present.
|
- If `payload.to` is set, cron auto-delivers the agent’s final output even if `payload.deliver` is omitted.
|
||||||
|
- Use `payload.deliver: true` when you want last-route delivery without an explicit `to`.
|
||||||
|
- Use `payload.deliver: false` to keep output internal even if a `to` is present.
|
||||||
|
|
||||||
|
If `delivery` is set, it overrides legacy payload delivery fields and skips the legacy main-session summary.
|
||||||
|
|
||||||
Target format reminders:
|
Target format reminders:
|
||||||
|
|
||||||
|
|
@ -248,13 +268,14 @@ Recurring, isolated job with delivery:
|
||||||
"wakeMode": "next-heartbeat",
|
"wakeMode": "next-heartbeat",
|
||||||
"payload": {
|
"payload": {
|
||||||
"kind": "agentTurn",
|
"kind": "agentTurn",
|
||||||
"message": "Summarize overnight updates.",
|
"message": "Summarize overnight updates."
|
||||||
"deliver": true,
|
},
|
||||||
|
"delivery": {
|
||||||
|
"mode": "announce",
|
||||||
"channel": "slack",
|
"channel": "slack",
|
||||||
"to": "channel:C1234567890",
|
"to": "channel:C1234567890",
|
||||||
"bestEffortDeliver": true
|
"bestEffort": true
|
||||||
},
|
}
|
||||||
"isolation": { "postToMainPrefix": "Cron", "postToMainMode": "summary" }
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -263,7 +284,7 @@ Notes:
|
||||||
- `schedule.kind`: `at` (`atMs`), `every` (`everyMs`), or `cron` (`expr`, optional `tz`).
|
- `schedule.kind`: `at` (`atMs`), `every` (`everyMs`), or `cron` (`expr`, optional `tz`).
|
||||||
- `atMs` and `everyMs` are epoch milliseconds.
|
- `atMs` and `everyMs` are epoch milliseconds.
|
||||||
- `sessionTarget` must be `"main"` or `"isolated"` and must match `payload.kind`.
|
- `sessionTarget` must be `"main"` or `"isolated"` and must match `payload.kind`.
|
||||||
- Optional fields: `agentId`, `description`, `enabled`, `deleteAfterRun`, `isolation`.
|
- Optional fields: `agentId`, `description`, `enabled`, `deleteAfterRun`, `delivery`, `isolation`.
|
||||||
- `wakeMode` defaults to `"next-heartbeat"` when omitted.
|
- `wakeMode` defaults to `"next-heartbeat"` when omitted.
|
||||||
|
|
||||||
### cron.update params
|
### cron.update params
|
||||||
|
|
@ -341,7 +362,7 @@ openclaw cron add \
|
||||||
--wake now
|
--wake now
|
||||||
```
|
```
|
||||||
|
|
||||||
Recurring isolated job (deliver to WhatsApp):
|
Recurring isolated job (announce to WhatsApp):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
openclaw cron add \
|
openclaw cron add \
|
||||||
|
|
@ -350,7 +371,7 @@ openclaw cron add \
|
||||||
--tz "America/Los_Angeles" \
|
--tz "America/Los_Angeles" \
|
||||||
--session isolated \
|
--session isolated \
|
||||||
--message "Summarize inbox + calendar for today." \
|
--message "Summarize inbox + calendar for today." \
|
||||||
--deliver \
|
--announce \
|
||||||
--channel whatsapp \
|
--channel whatsapp \
|
||||||
--to "+15551234567"
|
--to "+15551234567"
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,8 @@ Cron jobs run at **exact times** and can run in isolated sessions without affect
|
||||||
- **Exact timing**: 5-field cron expressions with timezone support.
|
- **Exact timing**: 5-field cron expressions with timezone support.
|
||||||
- **Session isolation**: Runs in `cron:<jobId>` without polluting main history.
|
- **Session isolation**: Runs in `cron:<jobId>` without polluting main history.
|
||||||
- **Model overrides**: Use a cheaper or more powerful model per job.
|
- **Model overrides**: Use a cheaper or more powerful model per job.
|
||||||
- **Delivery control**: Can deliver directly to a channel; still posts a summary to main by default (configurable).
|
- **Delivery control**: Choose `announce` (summary), `deliver` (full output), or `none`. Legacy jobs still post a summary to main by default.
|
||||||
|
- **Immediate delivery**: Announce/deliver modes post directly without waiting for heartbeat.
|
||||||
- **No agent context needed**: Runs even if main session is idle or compacted.
|
- **No agent context needed**: Runs even if main session is idle or compacted.
|
||||||
- **One-shot support**: `--at` for precise future timestamps.
|
- **One-shot support**: `--at` for precise future timestamps.
|
||||||
|
|
||||||
|
|
@ -104,12 +105,12 @@ openclaw cron add \
|
||||||
--session isolated \
|
--session isolated \
|
||||||
--message "Generate today's briefing: weather, calendar, top emails, news summary." \
|
--message "Generate today's briefing: weather, calendar, top emails, news summary." \
|
||||||
--model opus \
|
--model opus \
|
||||||
--deliver \
|
--announce \
|
||||||
--channel whatsapp \
|
--channel whatsapp \
|
||||||
--to "+15551234567"
|
--to "+15551234567"
|
||||||
```
|
```
|
||||||
|
|
||||||
This runs at exactly 7:00 AM New York time, uses Opus for quality, and delivers directly to WhatsApp.
|
This runs at exactly 7:00 AM New York time, uses Opus for quality, and announces a summary directly to WhatsApp.
|
||||||
|
|
||||||
### Cron example: One-shot reminder
|
### Cron example: One-shot reminder
|
||||||
|
|
||||||
|
|
@ -173,7 +174,7 @@ The most efficient setup uses **both**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Daily morning briefing at 7am
|
# Daily morning briefing at 7am
|
||||||
openclaw cron add --name "Morning brief" --cron "0 7 * * *" --session isolated --message "..." --deliver
|
openclaw cron add --name "Morning brief" --cron "0 7 * * *" --session isolated --message "..." --announce
|
||||||
|
|
||||||
# Weekly project review on Mondays at 9am
|
# Weekly project review on Mondays at 9am
|
||||||
openclaw cron add --name "Weekly review" --cron "0 9 * * 1" --session isolated --message "..." --model opus
|
openclaw cron add --name "Weekly review" --cron "0 9 * * 1" --session isolated --message "..." --model opus
|
||||||
|
|
@ -245,7 +246,7 @@ Use `--session isolated` when you want:
|
||||||
|
|
||||||
- A clean slate without prior context
|
- A clean slate without prior context
|
||||||
- Different model or thinking settings
|
- Different model or thinking settings
|
||||||
- Output delivered directly to a channel (summary still posts to main by default)
|
- Announce summaries or deliver full output directly to a channel
|
||||||
- History that doesn't clutter main session
|
- History that doesn't clutter main session
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -256,7 +257,7 @@ openclaw cron add \
|
||||||
--message "Weekly codebase analysis..." \
|
--message "Weekly codebase analysis..." \
|
||||||
--model opus \
|
--model opus \
|
||||||
--thinking high \
|
--thinking high \
|
||||||
--deliver
|
--announce
|
||||||
```
|
```
|
||||||
|
|
||||||
## Cost Considerations
|
## Cost Considerations
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ Tip: run `openclaw cron --help` for the full command surface.
|
||||||
Update delivery settings without changing the message:
|
Update delivery settings without changing the message:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
openclaw cron edit <job-id> --deliver --channel telegram --to "123456789"
|
openclaw cron edit <job-id> --announce --channel telegram --to "123456789"
|
||||||
```
|
```
|
||||||
|
|
||||||
Disable delivery for an isolated job:
|
Disable delivery for an isolated job:
|
||||||
|
|
@ -29,3 +29,9 @@ Disable delivery for an isolated job:
|
||||||
```bash
|
```bash
|
||||||
openclaw cron edit <job-id> --no-deliver
|
openclaw cron edit <job-id> --no-deliver
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Deliver full output (instead of announce):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw cron edit <job-id> --deliver --channel slack --to "channel:C1234567890"
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,11 @@ you revoke it with `openclaw devices revoke --device <id> --role <role>`. See
|
||||||
- Logs: live tail of gateway file logs with filter/export (`logs.tail`)
|
- Logs: live tail of gateway file logs with filter/export (`logs.tail`)
|
||||||
- Update: run a package/git update + restart (`update.run`) with a restart report
|
- Update: run a package/git update + restart (`update.run`) with a restart report
|
||||||
|
|
||||||
|
Cron jobs panel notes:
|
||||||
|
|
||||||
|
- For isolated jobs, choose a delivery mode: legacy main summary, announce summary, deliver full output, or none.
|
||||||
|
- Channel/target fields appear when announce or deliver is selected.
|
||||||
|
|
||||||
## Chat behavior
|
## Chat behavior
|
||||||
|
|
||||||
- `chat.send` is **non-blocking**: it acks immediately with `{ runId, status: "started" }` and the response streams via `chat` events.
|
- `chat.send` is **non-blocking**: it acks immediately with `{ runId, status: "started" }` and the response streams via `chat` events.
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,7 @@ JOB SCHEMA (for add action):
|
||||||
"name": "string (optional)",
|
"name": "string (optional)",
|
||||||
"schedule": { ... }, // Required: when to run
|
"schedule": { ... }, // Required: when to run
|
||||||
"payload": { ... }, // Required: what to execute
|
"payload": { ... }, // Required: what to execute
|
||||||
|
"delivery": { ... }, // Optional: announce/deliver output (isolated only)
|
||||||
"sessionTarget": "main" | "isolated", // Required
|
"sessionTarget": "main" | "isolated", // Required
|
||||||
"enabled": true | false // Optional, default true
|
"enabled": true | false // Optional, default true
|
||||||
}
|
}
|
||||||
|
|
@ -190,7 +191,13 @@ PAYLOAD TYPES (payload.kind):
|
||||||
- "systemEvent": Injects text as system event into session
|
- "systemEvent": Injects text as system event into session
|
||||||
{ "kind": "systemEvent", "text": "<message>" }
|
{ "kind": "systemEvent", "text": "<message>" }
|
||||||
- "agentTurn": Runs agent with message (isolated sessions only)
|
- "agentTurn": Runs agent with message (isolated sessions only)
|
||||||
{ "kind": "agentTurn", "message": "<prompt>", "model": "<optional>", "thinking": "<optional>", "timeoutSeconds": <optional>, "deliver": <optional-bool>, "channel": "<optional>", "to": "<optional>", "bestEffortDeliver": <optional-bool> }
|
{ "kind": "agentTurn", "message": "<prompt>", "model": "<optional>", "thinking": "<optional>", "timeoutSeconds": <optional> }
|
||||||
|
|
||||||
|
DELIVERY (isolated-only, top-level):
|
||||||
|
{ "mode": "none|announce|deliver", "channel": "<optional>", "to": "<optional>", "bestEffort": <optional-bool> }
|
||||||
|
|
||||||
|
LEGACY DELIVERY (payload, only when delivery is omitted):
|
||||||
|
{ "deliver": <optional-bool>, "channel": "<optional>", "to": "<optional>", "bestEffortDeliver": <optional-bool> }
|
||||||
|
|
||||||
CRITICAL CONSTRAINTS:
|
CRITICAL CONSTRAINTS:
|
||||||
- sessionTarget="main" REQUIRES payload.kind="systemEvent"
|
- sessionTarget="main" REQUIRES payload.kind="systemEvent"
|
||||||
|
|
|
||||||
|
|
@ -213,20 +213,15 @@ describe("cron cli", () => {
|
||||||
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
|
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
|
||||||
const patch = updateCall?.[2] as {
|
const patch = updateCall?.[2] as {
|
||||||
patch?: {
|
patch?: {
|
||||||
payload?: {
|
payload?: { kind?: string; message?: string };
|
||||||
kind?: string;
|
delivery?: { mode?: string; channel?: string; to?: string };
|
||||||
message?: string;
|
|
||||||
deliver?: boolean;
|
|
||||||
channel?: string;
|
|
||||||
to?: string;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(patch?.patch?.payload?.kind).toBe("agentTurn");
|
expect(patch?.patch?.payload?.kind).toBe("agentTurn");
|
||||||
expect(patch?.patch?.payload?.deliver).toBe(true);
|
expect(patch?.patch?.delivery?.mode).toBe("deliver");
|
||||||
expect(patch?.patch?.payload?.channel).toBe("telegram");
|
expect(patch?.patch?.delivery?.channel).toBe("telegram");
|
||||||
expect(patch?.patch?.payload?.to).toBe("19098680");
|
expect(patch?.patch?.delivery?.to).toBe("19098680");
|
||||||
expect(patch?.patch?.payload?.message).toBeUndefined();
|
expect(patch?.patch?.payload?.message).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -242,11 +237,11 @@ describe("cron cli", () => {
|
||||||
|
|
||||||
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
|
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
|
||||||
const patch = updateCall?.[2] as {
|
const patch = updateCall?.[2] as {
|
||||||
patch?: { payload?: { kind?: string; deliver?: boolean } };
|
patch?: { payload?: { kind?: string }; delivery?: { mode?: string } };
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(patch?.patch?.payload?.kind).toBe("agentTurn");
|
expect(patch?.patch?.payload?.kind).toBe("agentTurn");
|
||||||
expect(patch?.patch?.payload?.deliver).toBe(false);
|
expect(patch?.patch?.delivery?.mode).toBe("none");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not include undefined delivery fields when updating message", async () => {
|
it("does not include undefined delivery fields when updating message", async () => {
|
||||||
|
|
@ -272,6 +267,7 @@ describe("cron cli", () => {
|
||||||
to?: string;
|
to?: string;
|
||||||
bestEffortDeliver?: boolean;
|
bestEffortDeliver?: boolean;
|
||||||
};
|
};
|
||||||
|
delivery?: unknown;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -283,6 +279,7 @@ describe("cron cli", () => {
|
||||||
expect(patch?.patch?.payload).not.toHaveProperty("channel");
|
expect(patch?.patch?.payload).not.toHaveProperty("channel");
|
||||||
expect(patch?.patch?.payload).not.toHaveProperty("to");
|
expect(patch?.patch?.payload).not.toHaveProperty("to");
|
||||||
expect(patch?.patch?.payload).not.toHaveProperty("bestEffortDeliver");
|
expect(patch?.patch?.payload).not.toHaveProperty("bestEffortDeliver");
|
||||||
|
expect(patch?.patch).not.toHaveProperty("delivery");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes delivery fields when explicitly provided with message", async () => {
|
it("includes delivery fields when explicitly provided with message", async () => {
|
||||||
|
|
@ -313,20 +310,16 @@ describe("cron cli", () => {
|
||||||
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
|
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
|
||||||
const patch = updateCall?.[2] as {
|
const patch = updateCall?.[2] as {
|
||||||
patch?: {
|
patch?: {
|
||||||
payload?: {
|
payload?: { message?: string };
|
||||||
message?: string;
|
delivery?: { mode?: string; channel?: string; to?: string };
|
||||||
deliver?: boolean;
|
|
||||||
channel?: string;
|
|
||||||
to?: string;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Should include everything
|
// Should include everything
|
||||||
expect(patch?.patch?.payload?.message).toBe("Updated message");
|
expect(patch?.patch?.payload?.message).toBe("Updated message");
|
||||||
expect(patch?.patch?.payload?.deliver).toBe(true);
|
expect(patch?.patch?.delivery?.mode).toBe("deliver");
|
||||||
expect(patch?.patch?.payload?.channel).toBe("telegram");
|
expect(patch?.patch?.delivery?.channel).toBe("telegram");
|
||||||
expect(patch?.patch?.payload?.to).toBe("19098680");
|
expect(patch?.patch?.delivery?.to).toBe("19098680");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes best-effort delivery when provided with message", async () => {
|
it("includes best-effort delivery when provided with message", async () => {
|
||||||
|
|
|
||||||
|
|
@ -80,11 +80,12 @@ export function registerCronAddCommand(cron: Command) {
|
||||||
.option("--thinking <level>", "Thinking level for agent jobs (off|minimal|low|medium|high)")
|
.option("--thinking <level>", "Thinking level for agent jobs (off|minimal|low|medium|high)")
|
||||||
.option("--model <model>", "Model override for agent jobs (provider/model or alias)")
|
.option("--model <model>", "Model override for agent jobs (provider/model or alias)")
|
||||||
.option("--timeout-seconds <n>", "Timeout seconds for agent jobs")
|
.option("--timeout-seconds <n>", "Timeout seconds for agent jobs")
|
||||||
|
.option("--announce", "Announce summary to a chat (subagent-style)", false)
|
||||||
.option(
|
.option(
|
||||||
"--deliver",
|
"--deliver",
|
||||||
"Deliver agent output (required when using last-route delivery without --to)",
|
"Deliver full output to a chat (required when using last-route delivery without --to)",
|
||||||
false,
|
|
||||||
)
|
)
|
||||||
|
.option("--no-deliver", "Disable delivery and skip main-session summary")
|
||||||
.option("--channel <channel>", `Delivery channel (${getCronChannelOptions()})`, "last")
|
.option("--channel <channel>", `Delivery channel (${getCronChannelOptions()})`, "last")
|
||||||
.option(
|
.option(
|
||||||
"--to <dest>",
|
"--to <dest>",
|
||||||
|
|
@ -158,6 +159,15 @@ export function registerCronAddCommand(cron: Command) {
|
||||||
return { kind: "systemEvent" as const, text: systemEvent };
|
return { kind: "systemEvent" as const, text: systemEvent };
|
||||||
}
|
}
|
||||||
const timeoutSeconds = parsePositiveIntOrUndefined(opts.timeoutSeconds);
|
const timeoutSeconds = parsePositiveIntOrUndefined(opts.timeoutSeconds);
|
||||||
|
const hasAnnounce = Boolean(opts.announce);
|
||||||
|
const hasDeliver = opts.deliver === true;
|
||||||
|
const hasNoDeliver = opts.deliver === false;
|
||||||
|
const deliveryFlagCount = [hasAnnounce, hasDeliver, hasNoDeliver].filter(
|
||||||
|
Boolean,
|
||||||
|
).length;
|
||||||
|
if (deliveryFlagCount > 1) {
|
||||||
|
throw new Error("Choose at most one of --announce, --deliver, or --no-deliver");
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
kind: "agentTurn" as const,
|
kind: "agentTurn" as const,
|
||||||
message,
|
message,
|
||||||
|
|
@ -169,10 +179,15 @@ export function registerCronAddCommand(cron: Command) {
|
||||||
: undefined,
|
: undefined,
|
||||||
timeoutSeconds:
|
timeoutSeconds:
|
||||||
timeoutSeconds && Number.isFinite(timeoutSeconds) ? timeoutSeconds : undefined,
|
timeoutSeconds && Number.isFinite(timeoutSeconds) ? timeoutSeconds : undefined,
|
||||||
deliver: opts.deliver ? true : undefined,
|
channel:
|
||||||
channel: typeof opts.channel === "string" ? opts.channel : "last",
|
typeof opts.channel === "string" && opts.channel.trim()
|
||||||
|
? opts.channel.trim()
|
||||||
|
: "last",
|
||||||
to: typeof opts.to === "string" && opts.to.trim() ? opts.to.trim() : undefined,
|
to: typeof opts.to === "string" && opts.to.trim() ? opts.to.trim() : undefined,
|
||||||
bestEffortDeliver: opts.bestEffortDeliver ? true : undefined,
|
bestEffortDeliver:
|
||||||
|
!hasAnnounce && !hasDeliver && !hasNoDeliver && opts.bestEffortDeliver
|
||||||
|
? true
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
@ -182,6 +197,12 @@ export function registerCronAddCommand(cron: Command) {
|
||||||
if (sessionTarget === "isolated" && payload.kind !== "agentTurn") {
|
if (sessionTarget === "isolated" && payload.kind !== "agentTurn") {
|
||||||
throw new Error("Isolated jobs require --message (agentTurn).");
|
throw new Error("Isolated jobs require --message (agentTurn).");
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
(opts.announce || typeof opts.deliver === "boolean") &&
|
||||||
|
(sessionTarget !== "isolated" || payload.kind !== "agentTurn")
|
||||||
|
) {
|
||||||
|
throw new Error("--announce/--deliver/--no-deliver require --session isolated.");
|
||||||
|
}
|
||||||
|
|
||||||
const isolation =
|
const isolation =
|
||||||
sessionTarget === "isolated"
|
sessionTarget === "isolated"
|
||||||
|
|
@ -222,6 +243,20 @@ export function registerCronAddCommand(cron: Command) {
|
||||||
sessionTarget,
|
sessionTarget,
|
||||||
wakeMode,
|
wakeMode,
|
||||||
payload,
|
payload,
|
||||||
|
delivery:
|
||||||
|
payload.kind === "agentTurn" &&
|
||||||
|
sessionTarget === "isolated" &&
|
||||||
|
(opts.announce || typeof opts.deliver === "boolean")
|
||||||
|
? {
|
||||||
|
mode: opts.announce ? "announce" : opts.deliver === true ? "deliver" : "none",
|
||||||
|
channel:
|
||||||
|
typeof opts.channel === "string" && opts.channel.trim()
|
||||||
|
? opts.channel.trim()
|
||||||
|
: "last",
|
||||||
|
to: typeof opts.to === "string" && opts.to.trim() ? opts.to.trim() : undefined,
|
||||||
|
bestEffort: opts.bestEffortDeliver ? true : undefined,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
isolation,
|
isolation,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,9 +46,10 @@ export function registerCronEditCommand(cron: Command) {
|
||||||
.option("--thinking <level>", "Thinking level for agent jobs")
|
.option("--thinking <level>", "Thinking level for agent jobs")
|
||||||
.option("--model <model>", "Model override for agent jobs")
|
.option("--model <model>", "Model override for agent jobs")
|
||||||
.option("--timeout-seconds <n>", "Timeout seconds for agent jobs")
|
.option("--timeout-seconds <n>", "Timeout seconds for agent jobs")
|
||||||
|
.option("--announce", "Announce summary to a chat (subagent-style)")
|
||||||
.option(
|
.option(
|
||||||
"--deliver",
|
"--deliver",
|
||||||
"Deliver agent output (required when using last-route delivery without --to)",
|
"Deliver full output to a chat (required when using last-route delivery without --to)",
|
||||||
)
|
)
|
||||||
.option("--no-deliver", "Disable delivery")
|
.option("--no-deliver", "Disable delivery")
|
||||||
.option("--channel <channel>", `Delivery channel (${getCronChannelOptions()})`)
|
.option("--channel <channel>", `Delivery channel (${getCronChannelOptions()})`)
|
||||||
|
|
@ -74,6 +75,9 @@ export function registerCronEditCommand(cron: Command) {
|
||||||
if (opts.session === "main" && typeof opts.postPrefix === "string") {
|
if (opts.session === "main" && typeof opts.postPrefix === "string") {
|
||||||
throw new Error("--post-prefix only applies to isolated jobs.");
|
throw new Error("--post-prefix only applies to isolated jobs.");
|
||||||
}
|
}
|
||||||
|
if (opts.announce && typeof opts.deliver === "boolean") {
|
||||||
|
throw new Error("Choose --announce, --deliver, or --no-deliver (not multiple).");
|
||||||
|
}
|
||||||
|
|
||||||
const patch: Record<string, unknown> = {};
|
const patch: Record<string, unknown> = {};
|
||||||
if (typeof opts.name === "string") {
|
if (typeof opts.name === "string") {
|
||||||
|
|
@ -151,15 +155,16 @@ export function registerCronEditCommand(cron: Command) {
|
||||||
? Number.parseInt(String(opts.timeoutSeconds), 10)
|
? Number.parseInt(String(opts.timeoutSeconds), 10)
|
||||||
: undefined;
|
: undefined;
|
||||||
const hasTimeoutSeconds = Boolean(timeoutSeconds && Number.isFinite(timeoutSeconds));
|
const hasTimeoutSeconds = Boolean(timeoutSeconds && Number.isFinite(timeoutSeconds));
|
||||||
|
const hasDeliveryModeFlag = opts.announce || typeof opts.deliver === "boolean";
|
||||||
|
const hasDeliveryTarget = typeof opts.channel === "string" || typeof opts.to === "string";
|
||||||
|
const hasBestEffort = typeof opts.bestEffortDeliver === "boolean";
|
||||||
const hasAgentTurnPatch =
|
const hasAgentTurnPatch =
|
||||||
typeof opts.message === "string" ||
|
typeof opts.message === "string" ||
|
||||||
Boolean(model) ||
|
Boolean(model) ||
|
||||||
Boolean(thinking) ||
|
Boolean(thinking) ||
|
||||||
hasTimeoutSeconds ||
|
hasTimeoutSeconds ||
|
||||||
typeof opts.deliver === "boolean" ||
|
hasDeliveryModeFlag ||
|
||||||
typeof opts.channel === "string" ||
|
(!hasDeliveryModeFlag && (hasDeliveryTarget || hasBestEffort));
|
||||||
typeof opts.to === "string" ||
|
|
||||||
typeof opts.bestEffortDeliver === "boolean";
|
|
||||||
if (hasSystemEventPatch && hasAgentTurnPatch) {
|
if (hasSystemEventPatch && hasAgentTurnPatch) {
|
||||||
throw new Error("Choose at most one payload change");
|
throw new Error("Choose at most one payload change");
|
||||||
}
|
}
|
||||||
|
|
@ -174,15 +179,21 @@ export function registerCronEditCommand(cron: Command) {
|
||||||
assignIf(payload, "model", model, Boolean(model));
|
assignIf(payload, "model", model, Boolean(model));
|
||||||
assignIf(payload, "thinking", thinking, Boolean(thinking));
|
assignIf(payload, "thinking", thinking, Boolean(thinking));
|
||||||
assignIf(payload, "timeoutSeconds", timeoutSeconds, hasTimeoutSeconds);
|
assignIf(payload, "timeoutSeconds", timeoutSeconds, hasTimeoutSeconds);
|
||||||
assignIf(payload, "deliver", opts.deliver, typeof opts.deliver === "boolean");
|
if (!hasDeliveryModeFlag) {
|
||||||
assignIf(payload, "channel", opts.channel, typeof opts.channel === "string");
|
const channel =
|
||||||
assignIf(payload, "to", opts.to, typeof opts.to === "string");
|
typeof opts.channel === "string" && opts.channel.trim()
|
||||||
assignIf(
|
? opts.channel.trim()
|
||||||
payload,
|
: undefined;
|
||||||
"bestEffortDeliver",
|
const to = typeof opts.to === "string" && opts.to.trim() ? opts.to.trim() : undefined;
|
||||||
opts.bestEffortDeliver,
|
assignIf(payload, "channel", channel, Boolean(channel));
|
||||||
typeof opts.bestEffortDeliver === "boolean",
|
assignIf(payload, "to", to, Boolean(to));
|
||||||
);
|
assignIf(
|
||||||
|
payload,
|
||||||
|
"bestEffortDeliver",
|
||||||
|
opts.bestEffortDeliver,
|
||||||
|
typeof opts.bestEffortDeliver === "boolean",
|
||||||
|
);
|
||||||
|
}
|
||||||
patch.payload = payload;
|
patch.payload = payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -192,6 +203,24 @@ export function registerCronEditCommand(cron: Command) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasDeliveryModeFlag) {
|
||||||
|
const deliveryMode = opts.announce
|
||||||
|
? "announce"
|
||||||
|
: opts.deliver === true
|
||||||
|
? "deliver"
|
||||||
|
: "none";
|
||||||
|
patch.delivery = {
|
||||||
|
mode: deliveryMode,
|
||||||
|
channel:
|
||||||
|
typeof opts.channel === "string" && opts.channel.trim()
|
||||||
|
? opts.channel.trim()
|
||||||
|
: undefined,
|
||||||
|
to: typeof opts.to === "string" && opts.to.trim() ? opts.to.trim() : undefined,
|
||||||
|
bestEffort:
|
||||||
|
typeof opts.bestEffortDeliver === "boolean" ? opts.bestEffortDeliver : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const res = await callGatewayFromCli("cron.update", opts, {
|
const res = await callGatewayFromCli("cron.update", opts, {
|
||||||
id,
|
id,
|
||||||
patch,
|
patch,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
import type { CronDeliveryMode, CronJob, CronMessageChannel } from "./types.js";
|
||||||
|
|
||||||
|
export type CronDeliveryPlan = {
|
||||||
|
mode: CronDeliveryMode;
|
||||||
|
channel: CronMessageChannel;
|
||||||
|
to?: string;
|
||||||
|
bestEffort: boolean;
|
||||||
|
source: "delivery" | "payload";
|
||||||
|
requested: boolean;
|
||||||
|
legacyMode?: "explicit" | "auto" | "off";
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeChannel(value: unknown): CronMessageChannel | undefined {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const trimmed = value.trim().toLowerCase();
|
||||||
|
if (!trimmed) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return trimmed as CronMessageChannel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTo(value: unknown): string | undefined {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed ? trimmed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveCronDeliveryPlan(job: CronJob): CronDeliveryPlan {
|
||||||
|
const payload = job.payload.kind === "agentTurn" ? job.payload : null;
|
||||||
|
const delivery = job.delivery;
|
||||||
|
const hasDelivery = delivery && typeof delivery === "object";
|
||||||
|
const rawMode = hasDelivery ? (delivery as { mode?: unknown }).mode : undefined;
|
||||||
|
const mode =
|
||||||
|
rawMode === "none" || rawMode === "announce" || rawMode === "deliver" ? rawMode : undefined;
|
||||||
|
|
||||||
|
const payloadChannel = normalizeChannel(payload?.channel);
|
||||||
|
const payloadTo = normalizeTo(payload?.to);
|
||||||
|
const payloadBestEffort = payload?.bestEffortDeliver === true;
|
||||||
|
|
||||||
|
const deliveryChannel = normalizeChannel(
|
||||||
|
(delivery as { channel?: unknown } | undefined)?.channel,
|
||||||
|
);
|
||||||
|
const deliveryTo = normalizeTo((delivery as { to?: unknown } | undefined)?.to);
|
||||||
|
const deliveryBestEffortRaw = (delivery as { bestEffort?: unknown } | undefined)?.bestEffort;
|
||||||
|
const deliveryBestEffort =
|
||||||
|
typeof deliveryBestEffortRaw === "boolean" ? deliveryBestEffortRaw : undefined;
|
||||||
|
|
||||||
|
const channel = (deliveryChannel ?? payloadChannel ?? "last") as CronMessageChannel;
|
||||||
|
const to = deliveryTo ?? payloadTo;
|
||||||
|
if (hasDelivery) {
|
||||||
|
const resolvedMode = mode ?? "none";
|
||||||
|
return {
|
||||||
|
mode: resolvedMode,
|
||||||
|
channel,
|
||||||
|
to,
|
||||||
|
bestEffort: deliveryBestEffort ?? false,
|
||||||
|
source: "delivery",
|
||||||
|
requested: resolvedMode !== "none",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyMode =
|
||||||
|
payload?.deliver === true ? "explicit" : payload?.deliver === false ? "off" : "auto";
|
||||||
|
const hasExplicitTarget = Boolean(to);
|
||||||
|
const requested = legacyMode === "explicit" || (legacyMode === "auto" && hasExplicitTarget);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: requested ? "deliver" : "none",
|
||||||
|
channel,
|
||||||
|
to,
|
||||||
|
bestEffort: payloadBestEffort,
|
||||||
|
source: "payload",
|
||||||
|
requested,
|
||||||
|
legacyMode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,7 @@ export async function resolveDeliveryTarget(
|
||||||
channel: Exclude<OutboundChannel, "none">;
|
channel: Exclude<OutboundChannel, "none">;
|
||||||
to?: string;
|
to?: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
|
threadId?: string | number;
|
||||||
mode: "explicit" | "implicit";
|
mode: "explicit" | "implicit";
|
||||||
error?: Error;
|
error?: Error;
|
||||||
}> {
|
}> {
|
||||||
|
|
@ -69,7 +70,13 @@ export async function resolveDeliveryTarget(
|
||||||
const toCandidate = resolved.to;
|
const toCandidate = resolved.to;
|
||||||
|
|
||||||
if (!toCandidate) {
|
if (!toCandidate) {
|
||||||
return { channel, to: undefined, accountId: resolved.accountId, mode };
|
return {
|
||||||
|
channel,
|
||||||
|
to: undefined,
|
||||||
|
accountId: resolved.accountId,
|
||||||
|
threadId: resolved.threadId,
|
||||||
|
mode,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const docked = resolveOutboundTarget({
|
const docked = resolveOutboundTarget({
|
||||||
|
|
@ -83,6 +90,7 @@ export async function resolveDeliveryTarget(
|
||||||
channel,
|
channel,
|
||||||
to: docked.ok ? docked.to : undefined,
|
to: docked.ok ? docked.to : undefined,
|
||||||
accountId: resolved.accountId,
|
accountId: resolved.accountId,
|
||||||
|
threadId: resolved.threadId,
|
||||||
mode,
|
mode,
|
||||||
error: docked.ok ? undefined : docked.error,
|
error: docked.ok ? undefined : docked.error,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,10 @@ import {
|
||||||
import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
|
import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
|
||||||
import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js";
|
import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js";
|
||||||
import { getSkillsSnapshotVersion } from "../../agents/skills/refresh.js";
|
import { getSkillsSnapshotVersion } from "../../agents/skills/refresh.js";
|
||||||
|
import {
|
||||||
|
runSubagentAnnounceFlow,
|
||||||
|
type SubagentRunOutcome,
|
||||||
|
} from "../../agents/subagent-announce.js";
|
||||||
import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
|
import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
|
||||||
import { hasNonzeroUsage } from "../../agents/usage.js";
|
import { hasNonzeroUsage } from "../../agents/usage.js";
|
||||||
import { ensureAgentWorkspace } from "../../agents/workspace.js";
|
import { ensureAgentWorkspace } from "../../agents/workspace.js";
|
||||||
|
|
@ -41,7 +45,11 @@ import {
|
||||||
supportsXHighThinking,
|
supportsXHighThinking,
|
||||||
} from "../../auto-reply/thinking.js";
|
} from "../../auto-reply/thinking.js";
|
||||||
import { createOutboundSendDeps, type CliDeps } from "../../cli/outbound-send-deps.js";
|
import { createOutboundSendDeps, type CliDeps } from "../../cli/outbound-send-deps.js";
|
||||||
import { resolveSessionTranscriptPath, updateSessionStore } from "../../config/sessions.js";
|
import {
|
||||||
|
resolveAgentMainSessionKey,
|
||||||
|
resolveSessionTranscriptPath,
|
||||||
|
updateSessionStore,
|
||||||
|
} from "../../config/sessions.js";
|
||||||
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
||||||
import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js";
|
import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js";
|
||||||
import { getRemoteSkillEligibility } from "../../infra/skills-remote.js";
|
import { getRemoteSkillEligibility } from "../../infra/skills-remote.js";
|
||||||
|
|
@ -53,6 +61,7 @@ import {
|
||||||
getHookType,
|
getHookType,
|
||||||
isExternalHookSession,
|
isExternalHookSession,
|
||||||
} from "../../security/external-content.js";
|
} from "../../security/external-content.js";
|
||||||
|
import { resolveCronDeliveryPlan } from "../delivery.js";
|
||||||
import { resolveDeliveryTarget } from "./delivery-target.js";
|
import { resolveDeliveryTarget } from "./delivery-target.js";
|
||||||
import {
|
import {
|
||||||
isHeartbeatOnlyResponse,
|
isHeartbeatOnlyResponse,
|
||||||
|
|
@ -231,16 +240,15 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||||
});
|
});
|
||||||
|
|
||||||
const agentPayload = params.job.payload.kind === "agentTurn" ? params.job.payload : null;
|
const agentPayload = params.job.payload.kind === "agentTurn" ? params.job.payload : null;
|
||||||
const deliveryMode =
|
const deliveryPlan = resolveCronDeliveryPlan(params.job);
|
||||||
agentPayload?.deliver === true ? "explicit" : agentPayload?.deliver === false ? "off" : "auto";
|
const deliveryRequested = deliveryPlan.requested;
|
||||||
const hasExplicitTarget = Boolean(agentPayload?.to && agentPayload.to.trim());
|
const bestEffortDeliver = deliveryPlan.bestEffort;
|
||||||
const deliveryRequested =
|
const legacyDeliveryMode =
|
||||||
deliveryMode === "explicit" || (deliveryMode === "auto" && hasExplicitTarget);
|
deliveryPlan.source === "payload" ? deliveryPlan.legacyMode : undefined;
|
||||||
const bestEffortDeliver = agentPayload?.bestEffortDeliver === true;
|
|
||||||
|
|
||||||
const resolvedDelivery = await resolveDeliveryTarget(cfgWithAgentDefaults, agentId, {
|
const resolvedDelivery = await resolveDeliveryTarget(cfgWithAgentDefaults, agentId, {
|
||||||
channel: agentPayload?.channel ?? "last",
|
channel: deliveryPlan.channel ?? "last",
|
||||||
to: agentPayload?.to,
|
to: deliveryPlan.to,
|
||||||
});
|
});
|
||||||
|
|
||||||
const userTimezone = resolveUserTimezone(params.cfg.agents?.defaults?.userTimezone);
|
const userTimezone = resolveUserTimezone(params.cfg.agents?.defaults?.userTimezone);
|
||||||
|
|
@ -424,7 +432,7 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||||
const skipHeartbeatDelivery = deliveryRequested && isHeartbeatOnlyResponse(payloads, ackMaxChars);
|
const skipHeartbeatDelivery = deliveryRequested && isHeartbeatOnlyResponse(payloads, ackMaxChars);
|
||||||
const skipMessagingToolDelivery =
|
const skipMessagingToolDelivery =
|
||||||
deliveryRequested &&
|
deliveryRequested &&
|
||||||
deliveryMode === "auto" &&
|
legacyDeliveryMode === "auto" &&
|
||||||
runResult.didSendViaMessagingTool === true &&
|
runResult.didSendViaMessagingTool === true &&
|
||||||
(runResult.messagingToolSentTargets ?? []).some((target) =>
|
(runResult.messagingToolSentTargets ?? []).some((target) =>
|
||||||
matchesMessagingToolDeliveryTarget(target, {
|
matchesMessagingToolDeliveryTarget(target, {
|
||||||
|
|
@ -435,38 +443,70 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (deliveryRequested && !skipHeartbeatDelivery && !skipMessagingToolDelivery) {
|
if (deliveryRequested && !skipHeartbeatDelivery && !skipMessagingToolDelivery) {
|
||||||
if (!resolvedDelivery.to) {
|
if (deliveryPlan.mode === "announce") {
|
||||||
const reason =
|
const requesterSessionKey = resolveAgentMainSessionKey({
|
||||||
resolvedDelivery.error?.message ?? "Cron delivery requires a recipient (--to).";
|
cfg: cfgWithAgentDefaults,
|
||||||
if (!bestEffortDeliver) {
|
agentId,
|
||||||
|
});
|
||||||
|
const useExplicitOrigin = deliveryPlan.channel !== "last" || Boolean(deliveryPlan.to?.trim());
|
||||||
|
const requesterOrigin = useExplicitOrigin
|
||||||
|
? {
|
||||||
|
channel: resolvedDelivery.channel,
|
||||||
|
to: resolvedDelivery.to,
|
||||||
|
accountId: resolvedDelivery.accountId,
|
||||||
|
threadId: resolvedDelivery.threadId,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
const outcome: SubagentRunOutcome = { status: "ok" };
|
||||||
|
const taskLabel = params.job.name?.trim() || "cron job";
|
||||||
|
await runSubagentAnnounceFlow({
|
||||||
|
childSessionKey: agentSessionKey,
|
||||||
|
childRunId: cronSession.sessionEntry.sessionId,
|
||||||
|
requesterSessionKey,
|
||||||
|
requesterOrigin,
|
||||||
|
requesterDisplayKey: requesterSessionKey,
|
||||||
|
task: taskLabel,
|
||||||
|
timeoutMs: 30_000,
|
||||||
|
cleanup: "keep",
|
||||||
|
roundOneReply: outputText ?? summary,
|
||||||
|
waitForCompletion: false,
|
||||||
|
label: `Cron: ${taskLabel}`,
|
||||||
|
outcome,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (!resolvedDelivery.to) {
|
||||||
|
const reason =
|
||||||
|
resolvedDelivery.error?.message ?? "Cron delivery requires a recipient (--to).";
|
||||||
|
if (!bestEffortDeliver) {
|
||||||
|
return {
|
||||||
|
status: "error",
|
||||||
|
summary,
|
||||||
|
outputText,
|
||||||
|
error: reason,
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
status: "error",
|
status: "skipped",
|
||||||
summary,
|
summary: `Delivery skipped (${reason}).`,
|
||||||
outputText,
|
outputText,
|
||||||
error: reason,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
try {
|
||||||
status: "skipped",
|
await deliverOutboundPayloads({
|
||||||
summary: `Delivery skipped (${reason}).`,
|
cfg: cfgWithAgentDefaults,
|
||||||
outputText,
|
channel: resolvedDelivery.channel,
|
||||||
};
|
to: resolvedDelivery.to,
|
||||||
}
|
accountId: resolvedDelivery.accountId,
|
||||||
try {
|
payloads,
|
||||||
await deliverOutboundPayloads({
|
bestEffort: bestEffortDeliver,
|
||||||
cfg: cfgWithAgentDefaults,
|
deps: createOutboundSendDeps(params.deps),
|
||||||
channel: resolvedDelivery.channel,
|
});
|
||||||
to: resolvedDelivery.to,
|
} catch (err) {
|
||||||
accountId: resolvedDelivery.accountId,
|
if (!bestEffortDeliver) {
|
||||||
payloads,
|
return { status: "error", summary, outputText, error: String(err) };
|
||||||
bestEffort: bestEffortDeliver,
|
}
|
||||||
deps: createOutboundSendDeps(params.deps),
|
return { status: "ok", summary, outputText };
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
if (!bestEffortDeliver) {
|
|
||||||
return { status: "error", summary, outputText, error: String(err) };
|
|
||||||
}
|
}
|
||||||
return { status: "ok", summary, outputText };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -110,4 +110,28 @@ describe("normalizeCronJobCreate", () => {
|
||||||
expect(schedule.kind).toBe("at");
|
expect(schedule.kind).toBe("at");
|
||||||
expect(schedule.atMs).toBe(Date.parse("2026-01-12T18:00:00Z"));
|
expect(schedule.atMs).toBe(Date.parse("2026-01-12T18:00:00Z"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("normalizes delivery mode and channel", () => {
|
||||||
|
const normalized = normalizeCronJobCreate({
|
||||||
|
name: "delivery",
|
||||||
|
enabled: true,
|
||||||
|
schedule: { kind: "cron", expr: "* * * * *" },
|
||||||
|
sessionTarget: "isolated",
|
||||||
|
wakeMode: "now",
|
||||||
|
payload: {
|
||||||
|
kind: "agentTurn",
|
||||||
|
message: "hi",
|
||||||
|
},
|
||||||
|
delivery: {
|
||||||
|
mode: " ANNOUNCE ",
|
||||||
|
channel: " TeLeGrAm ",
|
||||||
|
to: " 7200373102 ",
|
||||||
|
},
|
||||||
|
}) as unknown as Record<string, unknown>;
|
||||||
|
|
||||||
|
const delivery = normalized.delivery as Record<string, unknown>;
|
||||||
|
expect(delivery.mode).toBe("announce");
|
||||||
|
expect(delivery.channel).toBe("telegram");
|
||||||
|
expect(delivery.to).toBe("7200373102");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,30 @@ function coercePayload(payload: UnknownRecord) {
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function coerceDelivery(delivery: UnknownRecord) {
|
||||||
|
const next: UnknownRecord = { ...delivery };
|
||||||
|
if (typeof delivery.mode === "string") {
|
||||||
|
next.mode = delivery.mode.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
if (typeof delivery.channel === "string") {
|
||||||
|
const trimmed = delivery.channel.trim().toLowerCase();
|
||||||
|
if (trimmed) {
|
||||||
|
next.channel = trimmed;
|
||||||
|
} else {
|
||||||
|
delete next.channel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof delivery.to === "string") {
|
||||||
|
const trimmed = delivery.to.trim();
|
||||||
|
if (trimmed) {
|
||||||
|
next.to = trimmed;
|
||||||
|
} else {
|
||||||
|
delete next.to;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
function unwrapJob(raw: UnknownRecord) {
|
function unwrapJob(raw: UnknownRecord) {
|
||||||
if (isRecord(raw.data)) {
|
if (isRecord(raw.data)) {
|
||||||
return raw.data;
|
return raw.data;
|
||||||
|
|
@ -118,6 +142,10 @@ export function normalizeCronJobInput(
|
||||||
next.payload = coercePayload(base.payload);
|
next.payload = coercePayload(base.payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isRecord(base.delivery)) {
|
||||||
|
next.delivery = coerceDelivery(base.delivery);
|
||||||
|
}
|
||||||
|
|
||||||
if (options.applyDefaults) {
|
if (options.applyDefaults) {
|
||||||
if (!next.wakeMode) {
|
if (!next.wakeMode) {
|
||||||
next.wakeMode = "next-heartbeat";
|
next.wakeMode = "next-heartbeat";
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import type {
|
import type {
|
||||||
|
CronDelivery,
|
||||||
|
CronDeliveryPatch,
|
||||||
CronJob,
|
CronJob,
|
||||||
CronJobCreate,
|
CronJobCreate,
|
||||||
CronJobPatch,
|
CronJobPatch,
|
||||||
|
|
@ -26,6 +28,12 @@ export function assertSupportedJobSpec(job: Pick<CronJob, "sessionTarget" | "pay
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertDeliverySupport(job: Pick<CronJob, "sessionTarget" | "delivery">) {
|
||||||
|
if (job.delivery && job.sessionTarget !== "isolated") {
|
||||||
|
throw new Error('cron delivery config is only supported for sessionTarget="isolated"');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function findJobOrThrow(state: CronServiceState, id: string) {
|
export function findJobOrThrow(state: CronServiceState, id: string) {
|
||||||
const job = state.store?.jobs.find((j) => j.id === id);
|
const job = state.store?.jobs.find((j) => j.id === id);
|
||||||
if (!job) {
|
if (!job) {
|
||||||
|
|
@ -102,12 +110,14 @@ export function createJob(state: CronServiceState, input: CronJobCreate): CronJo
|
||||||
sessionTarget: input.sessionTarget,
|
sessionTarget: input.sessionTarget,
|
||||||
wakeMode: input.wakeMode,
|
wakeMode: input.wakeMode,
|
||||||
payload: input.payload,
|
payload: input.payload,
|
||||||
|
delivery: input.delivery,
|
||||||
isolation: input.isolation,
|
isolation: input.isolation,
|
||||||
state: {
|
state: {
|
||||||
...input.state,
|
...input.state,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
assertSupportedJobSpec(job);
|
assertSupportedJobSpec(job);
|
||||||
|
assertDeliverySupport(job);
|
||||||
job.state.nextRunAtMs = computeJobNextRunAtMs(job, now);
|
job.state.nextRunAtMs = computeJobNextRunAtMs(job, now);
|
||||||
return job;
|
return job;
|
||||||
}
|
}
|
||||||
|
|
@ -137,6 +147,9 @@ export function applyJobPatch(job: CronJob, patch: CronJobPatch) {
|
||||||
if (patch.payload) {
|
if (patch.payload) {
|
||||||
job.payload = mergeCronPayload(job.payload, patch.payload);
|
job.payload = mergeCronPayload(job.payload, patch.payload);
|
||||||
}
|
}
|
||||||
|
if (patch.delivery) {
|
||||||
|
job.delivery = mergeCronDelivery(job.delivery, patch.delivery);
|
||||||
|
}
|
||||||
if (patch.isolation) {
|
if (patch.isolation) {
|
||||||
job.isolation = patch.isolation;
|
job.isolation = patch.isolation;
|
||||||
}
|
}
|
||||||
|
|
@ -147,6 +160,7 @@ export function applyJobPatch(job: CronJob, patch: CronJobPatch) {
|
||||||
job.agentId = normalizeOptionalAgentId((patch as { agentId?: unknown }).agentId);
|
job.agentId = normalizeOptionalAgentId((patch as { agentId?: unknown }).agentId);
|
||||||
}
|
}
|
||||||
assertSupportedJobSpec(job);
|
assertSupportedJobSpec(job);
|
||||||
|
assertDeliverySupport(job);
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeCronPayload(existing: CronPayload, patch: CronPayloadPatch): CronPayload {
|
function mergeCronPayload(existing: CronPayload, patch: CronPayloadPatch): CronPayload {
|
||||||
|
|
@ -219,6 +233,35 @@ function buildPayloadFromPatch(patch: CronPayloadPatch): CronPayload {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeCronDelivery(
|
||||||
|
existing: CronDelivery | undefined,
|
||||||
|
patch: CronDeliveryPatch,
|
||||||
|
): CronDelivery {
|
||||||
|
const next: CronDelivery = {
|
||||||
|
mode: existing?.mode ?? "none",
|
||||||
|
channel: existing?.channel,
|
||||||
|
to: existing?.to,
|
||||||
|
bestEffort: existing?.bestEffort,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof patch.mode === "string") {
|
||||||
|
next.mode = patch.mode;
|
||||||
|
}
|
||||||
|
if ("channel" in patch) {
|
||||||
|
const channel = typeof patch.channel === "string" ? patch.channel.trim() : "";
|
||||||
|
next.channel = channel ? channel : undefined;
|
||||||
|
}
|
||||||
|
if ("to" in patch) {
|
||||||
|
const to = typeof patch.to === "string" ? patch.to.trim() : "";
|
||||||
|
next.to = to ? to : undefined;
|
||||||
|
}
|
||||||
|
if (typeof patch.bestEffort === "boolean") {
|
||||||
|
next.bestEffort = patch.bestEffort;
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
export function isJobDue(job: CronJob, nowMs: number, opts: { forced: boolean }) {
|
export function isJobDue(job: CronJob, nowMs: number, opts: { forced: boolean }) {
|
||||||
if (opts.forced) {
|
if (opts.forced) {
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -125,7 +125,7 @@ export async function executeJob(
|
||||||
emit(state, { jobId: job.id, action: "removed" });
|
emit(state, { jobId: job.id, action: "removed" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (job.sessionTarget === "isolated") {
|
if (job.sessionTarget === "isolated" && !job.delivery) {
|
||||||
const prefix = job.isolation?.postToMainPrefix?.trim() || "Cron";
|
const prefix = job.isolation?.postToMainPrefix?.trim() || "Cron";
|
||||||
const mode = job.isolation?.postToMainMode ?? "summary";
|
const mode = job.isolation?.postToMainMode ?? "summary";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,17 @@ export type CronWakeMode = "next-heartbeat" | "now";
|
||||||
|
|
||||||
export type CronMessageChannel = ChannelId | "last";
|
export type CronMessageChannel = ChannelId | "last";
|
||||||
|
|
||||||
|
export type CronDeliveryMode = "none" | "announce" | "deliver";
|
||||||
|
|
||||||
|
export type CronDelivery = {
|
||||||
|
mode: CronDeliveryMode;
|
||||||
|
channel?: CronMessageChannel;
|
||||||
|
to?: string;
|
||||||
|
bestEffort?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CronDeliveryPatch = Partial<CronDelivery>;
|
||||||
|
|
||||||
export type CronPayload =
|
export type CronPayload =
|
||||||
| { kind: "systemEvent"; text: string }
|
| { kind: "systemEvent"; text: string }
|
||||||
| {
|
| {
|
||||||
|
|
@ -75,6 +86,7 @@ export type CronJob = {
|
||||||
sessionTarget: CronSessionTarget;
|
sessionTarget: CronSessionTarget;
|
||||||
wakeMode: CronWakeMode;
|
wakeMode: CronWakeMode;
|
||||||
payload: CronPayload;
|
payload: CronPayload;
|
||||||
|
delivery?: CronDelivery;
|
||||||
isolation?: CronIsolation;
|
isolation?: CronIsolation;
|
||||||
state: CronJobState;
|
state: CronJobState;
|
||||||
};
|
};
|
||||||
|
|
@ -90,5 +102,6 @@ export type CronJobCreate = Omit<CronJob, "id" | "createdAtMs" | "updatedAtMs" |
|
||||||
|
|
||||||
export type CronJobPatch = Partial<Omit<CronJob, "id" | "createdAtMs" | "state" | "payload">> & {
|
export type CronJobPatch = Partial<Omit<CronJob, "id" | "createdAtMs" | "state" | "payload">> & {
|
||||||
payload?: CronPayloadPatch;
|
payload?: CronPayloadPatch;
|
||||||
|
delivery?: CronDeliveryPatch;
|
||||||
state?: Partial<CronJobState>;
|
state?: Partial<CronJobState>;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,28 @@ export const CronPayloadPatchSchema = Type.Union([
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export const CronDeliverySchema = Type.Object(
|
||||||
|
{
|
||||||
|
mode: Type.Union([Type.Literal("none"), Type.Literal("announce"), Type.Literal("deliver")]),
|
||||||
|
channel: Type.Optional(Type.Union([Type.Literal("last"), NonEmptyString])),
|
||||||
|
to: Type.Optional(Type.String()),
|
||||||
|
bestEffort: Type.Optional(Type.Boolean()),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const CronDeliveryPatchSchema = Type.Object(
|
||||||
|
{
|
||||||
|
mode: Type.Optional(
|
||||||
|
Type.Union([Type.Literal("none"), Type.Literal("announce"), Type.Literal("deliver")]),
|
||||||
|
),
|
||||||
|
channel: Type.Optional(Type.Union([Type.Literal("last"), NonEmptyString])),
|
||||||
|
to: Type.Optional(Type.String()),
|
||||||
|
bestEffort: Type.Optional(Type.Boolean()),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
);
|
||||||
|
|
||||||
export const CronIsolationSchema = Type.Object(
|
export const CronIsolationSchema = Type.Object(
|
||||||
{
|
{
|
||||||
postToMainPrefix: Type.Optional(Type.String()),
|
postToMainPrefix: Type.Optional(Type.String()),
|
||||||
|
|
@ -112,6 +134,7 @@ export const CronJobSchema = Type.Object(
|
||||||
sessionTarget: Type.Union([Type.Literal("main"), Type.Literal("isolated")]),
|
sessionTarget: Type.Union([Type.Literal("main"), Type.Literal("isolated")]),
|
||||||
wakeMode: Type.Union([Type.Literal("next-heartbeat"), Type.Literal("now")]),
|
wakeMode: Type.Union([Type.Literal("next-heartbeat"), Type.Literal("now")]),
|
||||||
payload: CronPayloadSchema,
|
payload: CronPayloadSchema,
|
||||||
|
delivery: Type.Optional(CronDeliverySchema),
|
||||||
isolation: Type.Optional(CronIsolationSchema),
|
isolation: Type.Optional(CronIsolationSchema),
|
||||||
state: CronJobStateSchema,
|
state: CronJobStateSchema,
|
||||||
},
|
},
|
||||||
|
|
@ -138,6 +161,7 @@ export const CronAddParamsSchema = Type.Object(
|
||||||
sessionTarget: Type.Union([Type.Literal("main"), Type.Literal("isolated")]),
|
sessionTarget: Type.Union([Type.Literal("main"), Type.Literal("isolated")]),
|
||||||
wakeMode: Type.Union([Type.Literal("next-heartbeat"), Type.Literal("now")]),
|
wakeMode: Type.Union([Type.Literal("next-heartbeat"), Type.Literal("now")]),
|
||||||
payload: CronPayloadSchema,
|
payload: CronPayloadSchema,
|
||||||
|
delivery: Type.Optional(CronDeliverySchema),
|
||||||
isolation: Type.Optional(CronIsolationSchema),
|
isolation: Type.Optional(CronIsolationSchema),
|
||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
|
|
@ -154,6 +178,7 @@ export const CronJobPatchSchema = Type.Object(
|
||||||
sessionTarget: Type.Optional(Type.Union([Type.Literal("main"), Type.Literal("isolated")])),
|
sessionTarget: Type.Optional(Type.Union([Type.Literal("main"), Type.Literal("isolated")])),
|
||||||
wakeMode: Type.Optional(Type.Union([Type.Literal("next-heartbeat"), Type.Literal("now")])),
|
wakeMode: Type.Optional(Type.Union([Type.Literal("next-heartbeat"), Type.Literal("now")])),
|
||||||
payload: Type.Optional(CronPayloadPatchSchema),
|
payload: Type.Optional(CronPayloadPatchSchema),
|
||||||
|
delivery: Type.Optional(CronDeliveryPatchSchema),
|
||||||
isolation: Type.Optional(CronIsolationSchema),
|
isolation: Type.Optional(CronIsolationSchema),
|
||||||
state: Type.Optional(Type.Partial(CronJobStateSchema)),
|
state: Type.Optional(Type.Partial(CronJobStateSchema)),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,9 @@ export const DEFAULT_CRON_FORM: CronFormState = {
|
||||||
wakeMode: "next-heartbeat",
|
wakeMode: "next-heartbeat",
|
||||||
payloadKind: "systemEvent",
|
payloadKind: "systemEvent",
|
||||||
payloadText: "",
|
payloadText: "",
|
||||||
deliver: false,
|
deliveryMode: "legacy",
|
||||||
channel: "last",
|
deliveryChannel: "last",
|
||||||
to: "",
|
deliveryTo: "",
|
||||||
timeoutSeconds: "",
|
timeoutSeconds: "",
|
||||||
postToMainPrefix: "",
|
postToMainPrefix: "",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -88,20 +88,8 @@ export function buildCronPayload(form: CronFormState) {
|
||||||
const payload: {
|
const payload: {
|
||||||
kind: "agentTurn";
|
kind: "agentTurn";
|
||||||
message: string;
|
message: string;
|
||||||
deliver?: boolean;
|
|
||||||
channel?: string;
|
|
||||||
to?: string;
|
|
||||||
timeoutSeconds?: number;
|
timeoutSeconds?: number;
|
||||||
} = { kind: "agentTurn", message };
|
} = { kind: "agentTurn", message };
|
||||||
if (form.deliver) {
|
|
||||||
payload.deliver = true;
|
|
||||||
}
|
|
||||||
if (form.channel) {
|
|
||||||
payload.channel = form.channel;
|
|
||||||
}
|
|
||||||
if (form.to.trim()) {
|
|
||||||
payload.to = form.to.trim();
|
|
||||||
}
|
|
||||||
const timeoutSeconds = toNumber(form.timeoutSeconds, 0);
|
const timeoutSeconds = toNumber(form.timeoutSeconds, 0);
|
||||||
if (timeoutSeconds > 0) {
|
if (timeoutSeconds > 0) {
|
||||||
payload.timeoutSeconds = timeoutSeconds;
|
payload.timeoutSeconds = timeoutSeconds;
|
||||||
|
|
@ -118,6 +106,21 @@ export async function addCronJob(state: CronState) {
|
||||||
try {
|
try {
|
||||||
const schedule = buildCronSchedule(state.cronForm);
|
const schedule = buildCronSchedule(state.cronForm);
|
||||||
const payload = buildCronPayload(state.cronForm);
|
const payload = buildCronPayload(state.cronForm);
|
||||||
|
const delivery =
|
||||||
|
state.cronForm.sessionTarget === "isolated" &&
|
||||||
|
state.cronForm.payloadKind === "agentTurn" &&
|
||||||
|
state.cronForm.deliveryMode !== "legacy"
|
||||||
|
? {
|
||||||
|
mode:
|
||||||
|
state.cronForm.deliveryMode === "announce"
|
||||||
|
? "announce"
|
||||||
|
: state.cronForm.deliveryMode === "deliver"
|
||||||
|
? "deliver"
|
||||||
|
: "none",
|
||||||
|
channel: state.cronForm.deliveryChannel.trim() || "last",
|
||||||
|
to: state.cronForm.deliveryTo.trim() || undefined,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
const agentId = state.cronForm.agentId.trim();
|
const agentId = state.cronForm.agentId.trim();
|
||||||
const job = {
|
const job = {
|
||||||
name: state.cronForm.name.trim(),
|
name: state.cronForm.name.trim(),
|
||||||
|
|
@ -128,8 +131,11 @@ export async function addCronJob(state: CronState) {
|
||||||
sessionTarget: state.cronForm.sessionTarget,
|
sessionTarget: state.cronForm.sessionTarget,
|
||||||
wakeMode: state.cronForm.wakeMode,
|
wakeMode: state.cronForm.wakeMode,
|
||||||
payload,
|
payload,
|
||||||
|
delivery,
|
||||||
isolation:
|
isolation:
|
||||||
state.cronForm.postToMainPrefix.trim() && state.cronForm.sessionTarget === "isolated"
|
state.cronForm.postToMainPrefix.trim() &&
|
||||||
|
state.cronForm.sessionTarget === "isolated" &&
|
||||||
|
state.cronForm.deliveryMode === "legacy"
|
||||||
? { postToMainPrefix: state.cronForm.postToMainPrefix.trim() }
|
? { postToMainPrefix: state.cronForm.postToMainPrefix.trim() }
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -66,5 +66,18 @@ export function formatCronPayload(job: CronJob) {
|
||||||
if (p.kind === "systemEvent") {
|
if (p.kind === "systemEvent") {
|
||||||
return `System: ${p.text}`;
|
return `System: ${p.text}`;
|
||||||
}
|
}
|
||||||
return `Agent: ${p.message}`;
|
const base = `Agent: ${p.message}`;
|
||||||
|
const delivery = job.delivery;
|
||||||
|
if (delivery && delivery.mode !== "none") {
|
||||||
|
const target =
|
||||||
|
delivery.channel || delivery.to
|
||||||
|
? ` (${delivery.channel ?? "last"}${delivery.to ? ` -> ${delivery.to}` : ""})`
|
||||||
|
: "";
|
||||||
|
return `${base} · ${delivery.mode}${target}`;
|
||||||
|
}
|
||||||
|
if (!delivery && (p.deliver || p.to)) {
|
||||||
|
const target = p.channel || p.to ? ` (${p.channel ?? "last"}${p.to ? ` -> ${p.to}` : ""})` : "";
|
||||||
|
return `${base} · deliver${target}`;
|
||||||
|
}
|
||||||
|
return base;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -440,7 +440,7 @@ export type CronPayload =
|
||||||
thinking?: string;
|
thinking?: string;
|
||||||
timeoutSeconds?: number;
|
timeoutSeconds?: number;
|
||||||
deliver?: boolean;
|
deliver?: boolean;
|
||||||
provider?:
|
channel?:
|
||||||
| "last"
|
| "last"
|
||||||
| "whatsapp"
|
| "whatsapp"
|
||||||
| "telegram"
|
| "telegram"
|
||||||
|
|
@ -453,6 +453,13 @@ export type CronPayload =
|
||||||
bestEffortDeliver?: boolean;
|
bestEffortDeliver?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CronDelivery = {
|
||||||
|
mode: "none" | "announce" | "deliver";
|
||||||
|
channel?: string;
|
||||||
|
to?: string;
|
||||||
|
bestEffort?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type CronIsolation = {
|
export type CronIsolation = {
|
||||||
postToMainPrefix?: string;
|
postToMainPrefix?: string;
|
||||||
};
|
};
|
||||||
|
|
@ -479,6 +486,7 @@ export type CronJob = {
|
||||||
sessionTarget: CronSessionTarget;
|
sessionTarget: CronSessionTarget;
|
||||||
wakeMode: CronWakeMode;
|
wakeMode: CronWakeMode;
|
||||||
payload: CronPayload;
|
payload: CronPayload;
|
||||||
|
delivery?: CronDelivery;
|
||||||
isolation?: CronIsolation;
|
isolation?: CronIsolation;
|
||||||
state?: CronJobState;
|
state?: CronJobState;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -29,9 +29,9 @@ export type CronFormState = {
|
||||||
wakeMode: "next-heartbeat" | "now";
|
wakeMode: "next-heartbeat" | "now";
|
||||||
payloadKind: "systemEvent" | "agentTurn";
|
payloadKind: "systemEvent" | "agentTurn";
|
||||||
payloadText: string;
|
payloadText: string;
|
||||||
deliver: boolean;
|
deliveryMode: "legacy" | "none" | "announce" | "deliver";
|
||||||
channel: string;
|
deliveryChannel: string;
|
||||||
to: string;
|
deliveryTo: string;
|
||||||
timeoutSeconds: string;
|
timeoutSeconds: string;
|
||||||
postToMainPrefix: string;
|
postToMainPrefix: string;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ export type CronProps = {
|
||||||
|
|
||||||
function buildChannelOptions(props: CronProps): string[] {
|
function buildChannelOptions(props: CronProps): string[] {
|
||||||
const options = ["last", ...props.channels.filter(Boolean)];
|
const options = ["last", ...props.channels.filter(Boolean)];
|
||||||
const current = props.form.channel?.trim();
|
const current = props.form.deliveryChannel?.trim();
|
||||||
if (current && !options.includes(current)) {
|
if (current && !options.includes(current)) {
|
||||||
options.push(current);
|
options.push(current);
|
||||||
}
|
}
|
||||||
|
|
@ -197,77 +197,90 @@ export function renderCron(props: CronProps) {
|
||||||
rows="4"
|
rows="4"
|
||||||
></textarea>
|
></textarea>
|
||||||
</label>
|
</label>
|
||||||
${
|
${
|
||||||
props.form.payloadKind === "agentTurn"
|
props.form.payloadKind === "agentTurn"
|
||||||
? html`
|
? html`
|
||||||
<div class="form-grid" style="margin-top: 12px;">
|
<div class="form-grid" style="margin-top: 12px;">
|
||||||
<label class="field checkbox">
|
<label class="field">
|
||||||
<span>Deliver</span>
|
<span>Delivery</span>
|
||||||
<input
|
<select
|
||||||
type="checkbox"
|
.value=${props.form.deliveryMode}
|
||||||
.checked=${props.form.deliver}
|
@change=${(e: Event) =>
|
||||||
@change=${(e: Event) =>
|
|
||||||
props.onFormChange({
|
|
||||||
deliver: (e.target as HTMLInputElement).checked,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label class="field">
|
|
||||||
<span>Channel</span>
|
|
||||||
<select
|
|
||||||
.value=${props.form.channel || "last"}
|
|
||||||
@change=${(e: Event) =>
|
|
||||||
props.onFormChange({
|
props.onFormChange({
|
||||||
channel: (e.target as HTMLSelectElement).value,
|
deliveryMode: (e.target as HTMLSelectElement)
|
||||||
|
.value as CronFormState["deliveryMode"],
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
${channelOptions.map(
|
<option value="legacy">Main summary (legacy)</option>
|
||||||
(channel) =>
|
<option value="announce">Announce summary</option>
|
||||||
html`<option value=${channel}>
|
<option value="deliver">Deliver full output</option>
|
||||||
${resolveChannelLabel(props, channel)}
|
<option value="none">None (internal)</option>
|
||||||
</option>`,
|
</select>
|
||||||
)}
|
</label>
|
||||||
</select>
|
<label class="field">
|
||||||
</label>
|
<span>Timeout (seconds)</span>
|
||||||
<label class="field">
|
<input
|
||||||
<span>To</span>
|
.value=${props.form.timeoutSeconds}
|
||||||
<input
|
@input=${(e: Event) =>
|
||||||
.value=${props.form.to}
|
props.onFormChange({
|
||||||
@input=${(e: Event) =>
|
timeoutSeconds: (e.target as HTMLInputElement).value,
|
||||||
props.onFormChange({ to: (e.target as HTMLInputElement).value })}
|
})}
|
||||||
placeholder="+1555… or chat id"
|
/>
|
||||||
/>
|
</label>
|
||||||
</label>
|
${
|
||||||
<label class="field">
|
props.form.deliveryMode === "announce" || props.form.deliveryMode === "deliver"
|
||||||
<span>Timeout (seconds)</span>
|
? html`
|
||||||
<input
|
<label class="field">
|
||||||
.value=${props.form.timeoutSeconds}
|
<span>Channel</span>
|
||||||
@input=${(e: Event) =>
|
<select
|
||||||
props.onFormChange({
|
.value=${props.form.deliveryChannel || "last"}
|
||||||
timeoutSeconds: (e.target as HTMLInputElement).value,
|
@change=${(e: Event) =>
|
||||||
})}
|
props.onFormChange({
|
||||||
/>
|
deliveryChannel: (e.target as HTMLSelectElement).value,
|
||||||
</label>
|
})}
|
||||||
${
|
>
|
||||||
props.form.sessionTarget === "isolated"
|
${channelOptions.map(
|
||||||
? html`
|
(channel) =>
|
||||||
<label class="field">
|
html`<option value=${channel}>
|
||||||
<span>Post to main prefix</span>
|
${resolveChannelLabel(props, channel)}
|
||||||
<input
|
</option>`,
|
||||||
.value=${props.form.postToMainPrefix}
|
)}
|
||||||
@input=${(e: Event) =>
|
</select>
|
||||||
props.onFormChange({
|
</label>
|
||||||
postToMainPrefix: (e.target as HTMLInputElement).value,
|
<label class="field">
|
||||||
})}
|
<span>To</span>
|
||||||
/>
|
<input
|
||||||
</label>
|
.value=${props.form.deliveryTo}
|
||||||
`
|
@input=${(e: Event) =>
|
||||||
: nothing
|
props.onFormChange({
|
||||||
}
|
deliveryTo: (e.target as HTMLInputElement).value,
|
||||||
</div>
|
})}
|
||||||
`
|
placeholder="+1555… or chat id"
|
||||||
: nothing
|
/>
|
||||||
}
|
</label>
|
||||||
|
`
|
||||||
|
: nothing
|
||||||
|
}
|
||||||
|
${
|
||||||
|
props.form.sessionTarget === "isolated" && props.form.deliveryMode === "legacy"
|
||||||
|
? html`
|
||||||
|
<label class="field">
|
||||||
|
<span>Post to main prefix</span>
|
||||||
|
<input
|
||||||
|
.value=${props.form.postToMainPrefix}
|
||||||
|
@input=${(e: Event) =>
|
||||||
|
props.onFormChange({
|
||||||
|
postToMainPrefix: (e.target as HTMLInputElement).value,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
`
|
||||||
|
: nothing
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: nothing
|
||||||
|
}
|
||||||
<div class="row" style="margin-top: 14px;">
|
<div class="row" style="margin-top: 14px;">
|
||||||
<button class="btn primary" ?disabled=${props.busy} @click=${props.onAdd}>
|
<button class="btn primary" ?disabled=${props.busy} @click=${props.onAdd}>
|
||||||
${props.busy ? "Saving…" : "Add job"}
|
${props.busy ? "Saving…" : "Add job"}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue