Merge PR #1649: editable channel-aware scheduled delivery
This commit is contained in:
commit
438c578193
30 files changed
+1139
-100
No files matched your search
@@ -1551,6 +1551,8 @@ when an adapter succeeded without a provider receipt, and `sent` only when the
|
||||
adapter returned a provider message id (currently implemented end to end for
|
||||
Feishu). Failures remain retryable in the persisted outbox.
|
||||
|
||||
On the Web UI, scheduled jobs can be edited in place: prompt, cadence/timezone, and delivery settings are updated with `PATCH /scheduled-runs/{job_id}` without deleting the job, so its id and run history stay intact. A job that is currently running refuses edits until that dispatch finishes. Delivery remains operator-controlled: each channel adapter describes the destination label, placeholder, and input type, while the manual target field remains available. Adapters may also expose optional known-destination suggestions; selecting one only fills that same manual field. Telegram, for example, can suggest numeric private-chat ids already present in its local `allow_from` list, while usernames and wildcards are ignored.
|
||||
|
||||
**Five ready-to-schedule templates** ship with the scheduler — `premarket-brief`, `earnings-season-tracker`, `portfolio-checkup`, `a-share-money-flow`, `institutional-holdings-diff`. Each states the data a run needs in plain language instead of naming tools, so a template keeps working as the tool surface grows, and each is required to name a missing input rather than fill it from memory. Reach them from the CLI, over REST, or with `/playbook` in the TUI:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1244,6 +1244,8 @@ curl -X DELETE http://localhost:8899/scheduled-runs/<job_id>
|
||||
|
||||
يرى الوكيل أداة جدولة واحدة فقط هي `scheduled_research`: إجراءات القراءة تستعرض الحالة/المهام/القوالب، بينما `propose_create` و `propose_cancel` لا تحفظان سوى اقتراح تأكيد قصير الأجل ولا تعدّلان مخزن المهام أبدًا. يعرض الويب بطاقة تأكيد حتمية، ويسأل سطر الأوامر `y/N`، وتتطلب محادثات المراسلة ردًا حرفيًا `confirm` (`确认`) أو `cancel` (`取消`) — وهذه الإجراءات وحدها تستدعي نقطة الالتزام. بعد تجاوز `end_at` تصبح المهمة `expired` ولا تُنفَّذ مجددًا. التسليم محايد للقنوات: هيّئ مراجع أهداف معتمة قابلة لإعادة الاستخدام تحت `channels.deliveryTargets`، فلا يرى الوكيل وواجهات التأكيد سوى ref/label/channel دون معرّف الدردشة/المستخدم الخام لدى المزوّد. حالة التسليم `accepted` عندما ينجح المحوّل دون إيصال من المزوّد، و`sent` فقط عند إرجاع معرّف رسالة من المزوّد (مطبَّق حاليًا من طرف إلى طرف لـ Feishu).
|
||||
|
||||
في Web UI يمكن تعديل مهام البحث المجدولة في مكانها: يتم تحديث الـ prompt والوتيرة/المنطقة الزمنية وإعدادات التسليم عبر `PATCH /scheduled-runs/{job_id}` من دون حذف المهمة وإعادة إنشائها، لذلك يبقى المعرّف وسجل التشغيل محفوظين. ترفض المهمة الجاري تنفيذها التعديل حتى ينتهي ذلك التشغيل. يظل التسليم تحت تحكم المشغّل: يصف كل channel adapter تسمية حقل الوجهة والـ placeholder ونوع الإدخال، مع بقاء الإدخال اليدوي متاحًا. ويمكن للمحوّل أيضًا عرض اقتراحات اختيارية لوجهات معروفة؛ اختيار أحدها لا يفعل أكثر من ملء الحقل اليدوي نفسه. في Telegram مثلًا يمكن اقتراح معرّفات المحادثات الخاصة الرقمية الموجودة أصلًا في `allow_from` المحلي، مع تجاهل usernames والـ wildcards.
|
||||
|
||||
يأتي المجدول ومعه **خمسة قوالب بحث جاهزة للجدولة** — `premarket-brief` و`earnings-season-tracker` و`portfolio-checkup` و`a-share-money-flow` و`institutional-holdings-diff`. يصرّح كل قالب بالبيانات التي يحتاجها بلغة طبيعية بدل تسمية أداة بعينها، فيظل صالحاً مع توسّع مجموعة الأدوات، ويُطلب منه **ذكر أي مُدخل مفقود** بدل ملئه من الذاكرة. يمكن الوصول إليها من CLI أو REST أو عبر `/playbook` داخل واجهة TUI:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1304,6 +1304,8 @@ Cada disparo ejecuta el `prompt` a través de una sesión de agente nueva (los p
|
||||
|
||||
El agente ve exactamente una herramienta de programación, `scheduled_research`: las acciones de lectura inspeccionan estado/jobs/plantillas, y `propose_create` y `propose_cancel` solo persisten una propuesta de confirmación de corta duración; nunca mutan el almacén de jobs. La web muestra una tarjeta de confirmación determinista, la CLI pregunta `y/N`, y las conversaciones de IM requieren responder exactamente `confirm` (`确认`) o `cancel` (`取消`) — solo esa acción de superficie llama al endpoint de commit. Cuando pasa `end_at`, el job queda `expired` y no vuelve a ejecutarse. La entrega es agnóstica del canal: configura referencias opacas reutilizables bajo `channels.deliveryTargets`; el agente y las superficies de confirmación ven ref/label/channel pero nunca el chat/user id crudo del proveedor. El estado de entrega es `accepted` cuando un adaptador tuvo éxito sin recibo del proveedor, y `sent` solo cuando devolvió un id de mensaje del proveedor (actualmente implementado de extremo a extremo para Feishu).
|
||||
|
||||
En la Web UI, los jobs programados se pueden editar en el lugar: prompt, cadencia/zona horaria y entrega se actualizan con `PATCH /scheduled-runs/{job_id}` sin borrar el job, por lo que conserva su id y su historial de ejecuciones. Un job que está corriendo rechaza cambios hasta que termina ese disparo. La entrega sigue bajo control del operador: cada adaptador describe la etiqueta, el placeholder y el tipo de entrada del destino, y el campo manual continúa disponible. Los adaptadores también pueden ofrecer destinos conocidos opcionales; elegir uno solo completa ese mismo campo manual. Telegram, por ejemplo, puede sugerir ids numéricos de chats privados que ya figuren en su `allow_from` local, ignorando usernames y comodines.
|
||||
|
||||
**Cinco plantillas listas para programar** vienen incluidas con el scheduler — `premarket-brief`, `earnings-season-tracker`, `portfolio-checkup`, `a-share-money-flow`, `institutional-holdings-diff`. Cada una expresa en lenguaje natural los datos que necesita un run en lugar de nombrar herramientas, de modo que una plantilla sigue funcionando a medida que crece la superficie de herramientas, y cada una está obligada a señalar un input faltante en lugar de completarlo de memoria. Accede a ellas desde la CLI, mediante REST, o con `/playbook` en la TUI:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1549,6 +1549,8 @@ when an adapter succeeded without a provider receipt, and `sent` only when the
|
||||
adapter returned a provider message id (currently implemented end to end for
|
||||
Feishu). Failures remain retryable in the persisted outbox.
|
||||
|
||||
Di Web UI, job terjadwal dapat diedit langsung: prompt, cadence/timezone, dan delivery diperbarui melalui `PATCH /scheduled-runs/{job_id}` tanpa menghapus lalu membuat ulang job, sehingga id dan riwayat run tetap dipertahankan. Job yang sedang berjalan menolak edit sampai dispatch tersebut selesai. Delivery tetap dikendalikan operator: setiap channel adapter mendeskripsikan label, placeholder, dan tipe input destination, sementara field manual tetap tersedia. Adapter juga dapat menawarkan saran destination yang sudah dikenal secara opsional; memilih salah satunya hanya mengisi field manual yang sama. Telegram, misalnya, dapat menyarankan private-chat id numerik yang sudah ada di `allow_from` lokal, sedangkan username dan wildcard diabaikan.
|
||||
|
||||
**Lima template siap jadwal** tersedia bersama scheduler — `premarket-brief`, `earnings-season-tracker`, `portfolio-checkup`, `a-share-money-flow`, `institutional-holdings-diff`. Setiap template menyatakan data yang dibutuhkan run dalam bahasa biasa alih-alih menyebut tool, sehingga template tetap berfungsi saat surface tool berkembang, dan masing-masing wajib menyebut input yang hilang alih-alih mengisinya dari memory. Akses melalui CLI, REST, atau `/playbook` di TUI:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1246,6 +1246,8 @@ curl -X DELETE http://localhost:8899/scheduled-runs/<job_id>
|
||||
|
||||
エージェントに見えるスケジューリングツールは `scheduled_research` の 1 つだけです。読み取り系アクションは状態/ジョブ/テンプレートを照会し、`propose_create` と `propose_cancel` は短時間で失効する確認プロポーザルを保存するだけで、ジョブストアを直接変更することはありません。Web は決定的な確認カードを表示し、CLI は `y/N` を尋ね、IM 会話では正確に `confirm`(`确认`)または `cancel`(`取消`)と返信する必要があります——commit エンドポイントを呼ぶのはこれらの操作だけです。`end_at` を過ぎたジョブは `expired` になり、再実行されません。配信はチャネル非依存です。`channels.deliveryTargets` に再利用可能な不透明ターゲット参照を設定すると、エージェントと確認 UI には ref/label/channel のみが見え、プロバイダの生の chat/user id は渡りません。アダプタが受領証なしで成功した場合の配信状態は `accepted`、プロバイダのメッセージ id が返ったときだけ `sent` になります(現在は Feishu がエンドツーエンド対応)。
|
||||
|
||||
Web UI では既存の定期ジョブをその場で編集できます。prompt、cadence/timezone、配信設定は `PATCH /scheduled-runs/{job_id}` で更新され、ジョブを削除して作り直さないため id と実行履歴が維持されます。実行中のジョブはその実行が終わるまで編集を拒否します。配信先は引き続き operator が管理し、各 channel adapter が宛先フィールドのラベル、placeholder、input type を宣言しつつ、手入力もそのまま使えます。adapter は既知の宛先候補を任意で提示でき、選択すると同じ手入力フィールドを埋めるだけです。たとえば Telegram はローカルの `allow_from` にある数値の private-chat id を候補にでき、username と wildcard は候補にしません。
|
||||
|
||||
スケジューラには**すぐ使えるリサーチテンプレートが 5 本**同梱されています —— `premarket-brief`、`earnings-season-tracker`、`portfolio-checkup`、`a-share-money-flow`、`institutional-holdings-diff`。各テンプレートはツール名を挙げず、必要なデータを自然言語で宣言するため、ツール面が広がってもそのまま機能します。また、欠けている入力は記憶で埋めず**明示する**ことが求められます。CLI、REST、TUI の `/playbook` から利用できます:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1247,6 +1247,8 @@ curl -X DELETE http://localhost:8899/scheduled-runs/<job_id>
|
||||
|
||||
에이전트에게 보이는 스케줄링 도구는 `scheduled_research` 하나뿐입니다. 읽기 액션은 상태/작업/템플릿을 조회하고, `propose_create` 와 `propose_cancel` 은 짧게 유지되는 확인 제안만 저장할 뿐 작업 저장소를 직접 변경하지 않습니다. Web 은 결정적 확인 카드를 렌더링하고 CLI 는 `y/N` 을 물으며, IM 대화에서는 정확히 `confirm`(`确认`) 또는 `cancel`(`取消`) 로 답해야 합니다 — commit 엔드포인트를 호출하는 것은 이 표면 동작뿐입니다. `end_at` 이 지난 작업은 `expired` 가 되어 다시 실행되지 않습니다. 전달은 채널 중립적입니다. `channels.deliveryTargets` 아래에 재사용 가능한 불투명 대상 참조를 구성하면 에이전트와 확인 UI 에는 ref/label/channel 만 보이고 프로바이더의 원시 chat/user id 는 노출되지 않습니다. 어댑터가 영수증 없이 성공하면 전달 상태는 `accepted`, 프로바이더 메시지 id 가 반환될 때만 `sent` 입니다(현재 Feishu 가 엔드투엔드 지원).
|
||||
|
||||
Web UI에서는 기존 예약 작업을 제자리에서 편집할 수 있습니다. prompt, cadence/timezone, delivery 설정은 작업을 삭제하고 다시 만들지 않고 `PATCH /scheduled-runs/{job_id}`로 갱신되므로 id와 실행 이력이 유지됩니다. 실행 중인 작업은 현재 실행이 끝날 때까지 편집을 거부합니다. 전달 대상은 계속 operator가 제어하며 각 channel adapter가 대상 필드의 label, placeholder, input type을 설명하고 수동 입력도 그대로 사용할 수 있습니다. adapter는 선택적으로 알려진 대상 후보를 제공할 수 있고, 선택하면 같은 수동 입력 필드만 채웁니다. 예를 들어 Telegram은 로컬 `allow_from`에 이미 있는 숫자형 private-chat id를 제안할 수 있으며 username과 wildcard는 제외합니다.
|
||||
|
||||
스케줄러에는 **바로 예약할 수 있는 리서치 템플릿 5개**가 들어 있습니다 — `premarket-brief`, `earnings-season-tracker`, `portfolio-checkup`, `a-share-money-flow`, `institutional-holdings-diff`. 각 템플릿은 도구 이름을 지목하지 않고 필요한 데이터를 자연어로 선언하므로 도구 표면이 늘어나도 그대로 동작하며, 빠진 입력은 기억으로 채우지 말고 **밝히도록** 요구합니다. CLI, REST, TUI의 `/playbook` 어디서든 쓸 수 있습니다:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1251,6 +1251,8 @@ curl -X DELETE http://localhost:8899/scheduled-runs/<job_id>
|
||||
|
||||
agent 只有一个调度工具 `scheduled_research`:读操作查看状态/任务/模板;`propose_create` 与 `propose_cancel` 只落一份短时效的确认提案,绝不直接改动任务存储。Web 渲染确定性的确认卡片,CLI 询问 `y/N`,IM 会话需准确回复 `confirm`(`确认`)或 `cancel`(`取消`)——只有这些界面动作会调用 commit 端点。任务过了 `end_at` 即标记为 `expired`,不再触发。投递与通道解耦:在 `channels.deliveryTargets` 下配置可复用的不透明目标引用,agent 与确认界面只见 ref/label/channel,永远看不到平台原始 chat/user id;适配器无平台回执时投递状态为 `accepted`,仅当返回平台消息 id 时才是 `sent`(目前飞书已端到端支持)。
|
||||
|
||||
在 Web UI 中,已创建的定时任务可以原地编辑:prompt、频率/时区和投递设置通过 `PATCH /scheduled-runs/{job_id}` 更新,无需删除并重建,因此任务 id 和运行历史会保留。正在执行的任务会在本次执行结束前拒绝编辑。投递仍由 operator 控制:每个 channel adapter 可以声明目标字段的标签、placeholder 和输入类型,同时保留手工填写目标的方式。adapter 还可以提供可选的已知目标建议;选择建议只会填入同一个手工字段。例如 Telegram 可以建议本地 `allow_from` 中已有的数字私聊 id,而 username 和通配符不会被当作目标。
|
||||
|
||||
调度器自带**五个开箱即用的研究模板** —— `premarket-brief`、`earnings-season-tracker`、`portfolio-checkup`、`a-share-money-flow`、`institutional-holdings-diff`。每个模板用自然语言声明它需要什么数据,而不是点名某个工具,因此工具面扩展时模板依然有效;模板也被要求**指出缺失的输入**,而不是凭记忆补上。CLI、REST、TUI 里的 `/playbook` 三个入口都能用:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -231,6 +231,41 @@ class CreateScheduledRunRequest(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class UpdateScheduledRunRequest(BaseModel):
|
||||
"""Mutable authored fields for an existing scheduled research job.
|
||||
|
||||
Omitted fields are preserved. Explicit null clears nullable fields such as
|
||||
timezone, end_at, and delivery. Runtime/history fields are never client
|
||||
writable.
|
||||
"""
|
||||
|
||||
prompt: Optional[str] = Field(
|
||||
None, min_length=1, description="Replacement research prompt"
|
||||
)
|
||||
title: Optional[str] = Field(None, description="Human-readable monitor title")
|
||||
schedule: Optional[str] = Field(
|
||||
None, min_length=1, description="Replacement interval-ms or 5-field cron"
|
||||
)
|
||||
config: Optional[Dict[str, Any]] = Field(
|
||||
None, description="Replacement session/backtest config"
|
||||
)
|
||||
timezone: Optional[str] = Field(
|
||||
None, description="Replacement IANA timezone; null means UTC"
|
||||
)
|
||||
delivery_channel: Optional[str] = Field(
|
||||
None, description="Replacement delivery channel; null disables delivery"
|
||||
)
|
||||
delivery_target: Optional[str] = Field(
|
||||
None, description="Replacement address within the delivery channel"
|
||||
)
|
||||
delivery_target_ref: Optional[str] = Field(
|
||||
None, description="Replacement opaque configured target ref"
|
||||
)
|
||||
end_at: Optional[int] = Field(
|
||||
None, description="Replacement epoch-ms end boundary; null removes it"
|
||||
)
|
||||
|
||||
|
||||
class PlaybookResponse(BaseModel):
|
||||
"""Catalogue record for one research playbook template.
|
||||
|
||||
@@ -543,6 +578,170 @@ def register_scheduled_routes(
|
||||
except ProposalError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.patch(
|
||||
"/scheduled-runs/{job_id}",
|
||||
response_model=ScheduledRunResponse,
|
||||
dependencies=[Depends(require_auth)],
|
||||
)
|
||||
async def update_scheduled_run(
|
||||
job_id: str,
|
||||
request: UpdateScheduledRunRequest,
|
||||
) -> ScheduledRunResponse:
|
||||
"""Update future configuration without deleting the job or its history."""
|
||||
from src.scheduled_research.executor import next_due
|
||||
from src.scheduled_research.models import (
|
||||
DeliveryRecord,
|
||||
JobStatus,
|
||||
is_interval_schedule,
|
||||
validate_schedule,
|
||||
validate_timezone,
|
||||
validate_timezone_shape,
|
||||
)
|
||||
|
||||
_host_validate_path_param(job_id, "job_id")
|
||||
store = _get_scheduled_research_store()
|
||||
job = store.get(job_id)
|
||||
if job is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"scheduled run {job_id} not found"
|
||||
)
|
||||
if job.status == JobStatus.RUNNING:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="a running scheduled run cannot be edited; retry after it finishes",
|
||||
)
|
||||
|
||||
fields = request.model_fields_set
|
||||
if not fields:
|
||||
return _job_to_response(job)
|
||||
|
||||
prompt = job.prompt
|
||||
if "prompt" in fields:
|
||||
if request.prompt is None or not request.prompt.strip():
|
||||
raise HTTPException(status_code=422, detail="prompt must not be blank")
|
||||
prompt = request.prompt
|
||||
|
||||
title = job.title
|
||||
if "title" in fields:
|
||||
title = request.title or ""
|
||||
|
||||
schedule = job.schedule
|
||||
if "schedule" in fields:
|
||||
if request.schedule is None:
|
||||
raise HTTPException(status_code=422, detail="schedule must not be null")
|
||||
schedule = request.schedule
|
||||
|
||||
timezone = job.timezone if "timezone" not in fields else request.timezone
|
||||
config = job.config if "config" not in fields else (request.config or {})
|
||||
end_at = job.end_at if "end_at" not in fields else request.end_at
|
||||
|
||||
try:
|
||||
validate_schedule(schedule)
|
||||
if is_interval_schedule(schedule):
|
||||
validate_timezone_shape(timezone)
|
||||
else:
|
||||
validate_timezone(timezone)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
now_ms = int(time.time() * 1000)
|
||||
if "end_at" in fields and end_at is not None and end_at <= now_ms:
|
||||
raise HTTPException(status_code=422, detail="end_at must be in the future")
|
||||
|
||||
# Interval schedules are timezone-agnostic. A timezone-only edit must
|
||||
# not move their next fire time; only cron schedules use timezone as
|
||||
# part of their execution cadence.
|
||||
schedule_changed = schedule != job.schedule or (
|
||||
not is_interval_schedule(schedule) and timezone != job.timezone
|
||||
)
|
||||
next_run_at = job.next_run_at
|
||||
if schedule_changed:
|
||||
if is_interval_schedule(schedule):
|
||||
next_run_at = now_ms
|
||||
else:
|
||||
try:
|
||||
next_run_at = next_due(schedule, now_ms, timezone)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
if end_at is not None and next_run_at > end_at:
|
||||
raise HTTPException(
|
||||
status_code=422, detail="the next scheduled run occurs after end_at"
|
||||
)
|
||||
|
||||
delivery_fields = {
|
||||
"delivery_channel",
|
||||
"delivery_target",
|
||||
"delivery_target_ref",
|
||||
}
|
||||
delivery_requested = bool(delivery_fields & fields)
|
||||
delivery_channel = job.delivery_channel
|
||||
delivery_target = job.delivery_target
|
||||
delivery_target_ref = job.delivery_target_ref
|
||||
delivery_target_label = job.delivery_target_label
|
||||
|
||||
if delivery_requested:
|
||||
if request.delivery_target_ref:
|
||||
from src.channels.targets import resolve_delivery_target
|
||||
|
||||
try:
|
||||
resolved = resolve_delivery_target(request.delivery_target_ref)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
delivery_channel = resolved.channel
|
||||
delivery_target = resolved.target
|
||||
delivery_target_ref = resolved.ref
|
||||
delivery_target_label = resolved.label
|
||||
else:
|
||||
if "delivery_channel" in fields:
|
||||
new_channel = (request.delivery_channel or "").strip()
|
||||
if (
|
||||
new_channel != (job.delivery_channel or "")
|
||||
and "delivery_target" not in fields
|
||||
):
|
||||
delivery_target = None
|
||||
delivery_channel = new_channel or None
|
||||
if "delivery_target" in fields:
|
||||
new_target = (request.delivery_target or "").strip()
|
||||
delivery_target = new_target or None
|
||||
# A raw channel/target edit supersedes any prior opaque ref.
|
||||
delivery_target_ref = None
|
||||
delivery_target_label = None
|
||||
if delivery_channel is None:
|
||||
delivery_target = None
|
||||
elif delivery_target is None:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="delivery_target is required when delivery_channel is set",
|
||||
)
|
||||
|
||||
delivery_changed = delivery_requested and (
|
||||
delivery_channel != job.delivery_channel
|
||||
or delivery_target != job.delivery_target
|
||||
or delivery_target_ref != job.delivery_target_ref
|
||||
or delivery_target_label != job.delivery_target_label
|
||||
)
|
||||
|
||||
job.prompt = prompt
|
||||
job.title = title
|
||||
job.schedule = schedule
|
||||
job.config = config
|
||||
job.timezone = timezone
|
||||
job.end_at = end_at
|
||||
job.next_run_at = next_run_at
|
||||
if delivery_changed:
|
||||
job.delivery_channel = delivery_channel
|
||||
job.delivery_target = delivery_target
|
||||
job.delivery_target_ref = delivery_target_ref
|
||||
job.delivery_target_label = delivery_target_label
|
||||
# The previous outbox receipt describes the old destination. Clear
|
||||
# it rather than presenting that receipt as if it belonged to the
|
||||
# newly-authored delivery configuration.
|
||||
job.delivery = DeliveryRecord()
|
||||
|
||||
store.upsert(job)
|
||||
return _job_to_response(job)
|
||||
|
||||
@app.delete(
|
||||
"/scheduled-runs/{job_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
|
||||
@@ -33,6 +33,18 @@ class BaseChannel(ABC):
|
||||
|
||||
supports_connection_test: ClassVar[bool] = False
|
||||
|
||||
# Scheduled delivery is channel-agnostic at the scheduler layer. Adapters
|
||||
# describe the address they accept so generic UIs never need to hard-code
|
||||
# channel names or ask users to guess provider-specific target formats.
|
||||
delivery_target_label: ClassVar[str] = "Destination"
|
||||
delivery_target_kind: ClassVar[str] = "destination"
|
||||
delivery_target_placeholder: ClassVar[str] = "Chat, group, user, address, or channel ID"
|
||||
delivery_target_input_type: ClassVar[str] = "text"
|
||||
|
||||
def delivery_target_suggestions(self) -> list[dict[str, str]]:
|
||||
"""Return safe destination suggestions for scheduled delivery."""
|
||||
return []
|
||||
|
||||
def __init__(self, config: Any, bus: MessageBus) -> None:
|
||||
"""Initialize the channel.
|
||||
|
||||
|
||||
@@ -355,6 +355,9 @@ class DiscordChannel(BaseChannel):
|
||||
|
||||
name = "discord"
|
||||
display_name = "Discord"
|
||||
delivery_target_label = "Discord channel"
|
||||
delivery_target_kind = "channel"
|
||||
delivery_target_placeholder = "Channel ID"
|
||||
_STREAM_EDIT_INTERVAL = 0.8
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -99,6 +99,10 @@ class EmailChannel(BaseChannel):
|
||||
|
||||
name = "email"
|
||||
display_name = "Email"
|
||||
delivery_target_label = "Recipient email address"
|
||||
delivery_target_kind = "email_address"
|
||||
delivery_target_placeholder = "name@example.com"
|
||||
delivery_target_input_type = "email"
|
||||
supports_connection_test = True
|
||||
_IMAP_MONTHS = (
|
||||
"Jan",
|
||||
|
||||
@@ -573,12 +573,16 @@ class ChannelManager:
|
||||
status = {name: dict(item) for name, item in self._status.items()}
|
||||
for name, channel in self.channels.items():
|
||||
status.setdefault(name, {})
|
||||
target_suggestions = getattr(channel, "delivery_target_suggestions", None)
|
||||
status[name].update(
|
||||
{
|
||||
"enabled": True,
|
||||
"loaded": True,
|
||||
"running": channel.is_running,
|
||||
"display_name": getattr(channel, "display_name", name),
|
||||
"delivery_target_suggestions": (
|
||||
target_suggestions() if callable(target_suggestions) else []
|
||||
),
|
||||
}
|
||||
)
|
||||
return status
|
||||
|
||||
@@ -95,6 +95,10 @@ class ChannelAvailability:
|
||||
display_name: str
|
||||
error: str = ""
|
||||
install_hint: str = ""
|
||||
delivery_target_label: str = "Destination"
|
||||
delivery_target_kind: str = "destination"
|
||||
delivery_target_placeholder: str = "Chat, group, user, address, or channel ID"
|
||||
delivery_target_input_type: str = "text"
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Return a JSON-serializable availability payload."""
|
||||
@@ -104,6 +108,10 @@ class ChannelAvailability:
|
||||
"display_name": self.display_name,
|
||||
"error": self.error,
|
||||
"install_hint": self.install_hint,
|
||||
"delivery_target_label": self.delivery_target_label,
|
||||
"delivery_target_kind": self.delivery_target_kind,
|
||||
"delivery_target_placeholder": self.delivery_target_placeholder,
|
||||
"delivery_target_input_type": self.delivery_target_input_type,
|
||||
}
|
||||
|
||||
|
||||
@@ -165,6 +173,22 @@ def inspect_channel(name: str) -> ChannelAvailability:
|
||||
mod = importlib.import_module(f"src.channels.{name}")
|
||||
cls = _channel_class_from_module(mod, name)
|
||||
display = getattr(cls, "display_name", name)
|
||||
delivery_target_label = str(
|
||||
getattr(cls, "delivery_target_label", "Destination")
|
||||
)
|
||||
delivery_target_kind = str(
|
||||
getattr(cls, "delivery_target_kind", "destination")
|
||||
)
|
||||
delivery_target_placeholder = str(
|
||||
getattr(
|
||||
cls,
|
||||
"delivery_target_placeholder",
|
||||
"Chat, group, user, address, or channel ID",
|
||||
)
|
||||
)
|
||||
delivery_target_input_type = str(
|
||||
getattr(cls, "delivery_target_input_type", "text")
|
||||
)
|
||||
missing = _missing_optional_dependency(name, mod)
|
||||
if missing:
|
||||
return ChannelAvailability(
|
||||
@@ -175,8 +199,20 @@ def inspect_channel(name: str) -> ChannelAvailability:
|
||||
install_hint=_INSTALL_HINTS.get(
|
||||
name, f"pip install 'vibe-trading-ai[{name}]'"
|
||||
),
|
||||
delivery_target_label=delivery_target_label,
|
||||
delivery_target_kind=delivery_target_kind,
|
||||
delivery_target_placeholder=delivery_target_placeholder,
|
||||
delivery_target_input_type=delivery_target_input_type,
|
||||
)
|
||||
return ChannelAvailability(name=name, available=True, display_name=str(display))
|
||||
return ChannelAvailability(
|
||||
name=name,
|
||||
available=True,
|
||||
display_name=str(display),
|
||||
delivery_target_label=delivery_target_label,
|
||||
delivery_target_kind=delivery_target_kind,
|
||||
delivery_target_placeholder=delivery_target_placeholder,
|
||||
delivery_target_input_type=delivery_target_input_type,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - status API must report every adapter
|
||||
return ChannelAvailability(
|
||||
name=name,
|
||||
|
||||
@@ -73,6 +73,8 @@ class SlackChannel(BaseChannel):
|
||||
|
||||
name = "slack"
|
||||
display_name = "Slack"
|
||||
delivery_target_label = "Slack destination"
|
||||
delivery_target_placeholder = "#channel, @user, or conversation ID"
|
||||
_SLACK_ID_RE = re.compile(r"^[CDGUW][A-Z0-9]{2,}$")
|
||||
_SLACK_CHANNEL_REF_RE = re.compile(r"^<#([A-Z0-9]+)(?:\|[^>]+)?>$")
|
||||
_SLACK_USER_REF_RE = re.compile(r"^<@([A-Z0-9]+)(?:\|[^>]+)?>$")
|
||||
|
||||
@@ -430,6 +430,22 @@ class TelegramChannel(BaseChannel):
|
||||
|
||||
name = "telegram"
|
||||
display_name = "Telegram"
|
||||
delivery_target_label = "Telegram chat"
|
||||
delivery_target_kind = "chat"
|
||||
delivery_target_placeholder = "Chat, group, or user ID"
|
||||
|
||||
def delivery_target_suggestions(self) -> list[dict[str, str]]:
|
||||
"""Suggest numeric private chats already authorized in allow_from."""
|
||||
suggestions: list[dict[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
for item in self.config.allow_from:
|
||||
value = str(item).strip()
|
||||
candidate = value.split("|", 1)[0] if "|" in value else value
|
||||
if re.fullmatch(r"[0-9]+", candidate) is None or candidate in seen:
|
||||
continue
|
||||
seen.add(candidate)
|
||||
suggestions.append({"kind": "private_chat", "target": candidate})
|
||||
return suggestions
|
||||
|
||||
# Commands registered with Telegram's command menu
|
||||
BOT_COMMANDS = [
|
||||
|
||||
@@ -58,6 +58,10 @@ def test_channels_status_reports_all_configured_adapters(tmp_path: Path, monkeyp
|
||||
assert payload["channels"]["telegram"]["enabled"] is False
|
||||
assert payload["channels"]["slack"]["enabled"] is True
|
||||
assert "available" in payload["channels"]["slack"]
|
||||
assert payload["channels"]["email"]["delivery_target_label"] == "Recipient email address"
|
||||
assert payload["channels"]["email"]["delivery_target_kind"] == "email_address"
|
||||
assert payload["channels"]["email"]["delivery_target_input_type"] == "email"
|
||||
assert payload["channels"]["telegram"]["delivery_target_label"] == "Telegram chat"
|
||||
assert "reply_timeout_s" not in payload["channels"]
|
||||
assert "send_max_retries" not in payload["channels"]
|
||||
assert api_server._channel_runtime is not None
|
||||
|
||||
@@ -204,6 +204,30 @@ def test_registry_ignores_global_channel_settings() -> None:
|
||||
assert "model_dump" not in statuses
|
||||
|
||||
|
||||
def test_telegram_status_suggests_numeric_authorized_private_chats() -> None:
|
||||
from src.channels.registry import inspect_channel
|
||||
|
||||
if not inspect_channel("telegram").available:
|
||||
pytest.skip("telegram optional dependency not installed")
|
||||
|
||||
manager = ChannelManager(
|
||||
{
|
||||
"telegram": {
|
||||
"enabled": True,
|
||||
"token": "test-token",
|
||||
"allow_from": ["1381234567", "alice", "*", "1381234567|alice", "123456"],
|
||||
}
|
||||
},
|
||||
MessageBus(),
|
||||
)
|
||||
|
||||
status = manager.get_status()["telegram"]
|
||||
|
||||
assert status["delivery_target_suggestions"] == [
|
||||
{"kind": "private_chat", "target": "1381234567"}
|
||||
]
|
||||
|
||||
|
||||
def test_channel_manager_status_includes_every_configured_adapter() -> None:
|
||||
bus = MessageBus()
|
||||
manager = ChannelManager(
|
||||
|
||||
@@ -21,7 +21,12 @@ from fastapi.testclient import TestClient
|
||||
|
||||
import api_server
|
||||
from src.api import scheduled_routes
|
||||
from src.scheduled_research.models import JobStatus, ScheduledResearchJob
|
||||
from src.scheduled_research.models import (
|
||||
DeliveryRecord,
|
||||
DeliveryStatus,
|
||||
JobStatus,
|
||||
ScheduledResearchJob,
|
||||
)
|
||||
from src.scheduled_research.store import ScheduledResearchJobStore
|
||||
|
||||
|
||||
@@ -165,6 +170,141 @@ def test_list_rejects_out_of_range_limit(client: TestClient):
|
||||
assert client.get("/scheduled-runs", params={"limit": 500}).status_code == 422
|
||||
|
||||
|
||||
|
||||
def test_patch_updates_authored_fields_without_replacing_runtime_history(
|
||||
client: TestClient, store: ScheduledResearchJobStore
|
||||
):
|
||||
job = _seed(
|
||||
store,
|
||||
id="editable",
|
||||
prompt="old prompt",
|
||||
schedule="0 9 * * 1-5",
|
||||
timezone="UTC",
|
||||
status=JobStatus.COMPLETED,
|
||||
last_run_at=1_700_000_100_000,
|
||||
delivery_channel="telegram",
|
||||
delivery_target="123",
|
||||
)
|
||||
original_created_at = job.created_at
|
||||
|
||||
response = client.patch(
|
||||
"/scheduled-runs/editable",
|
||||
json={
|
||||
"prompt": "new prompt",
|
||||
"schedule": "30 10 * * 1-5",
|
||||
"timezone": "Europe/London",
|
||||
"delivery_channel": "email",
|
||||
"delivery_target": "person@example.com",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["prompt"] == "new prompt"
|
||||
assert body["schedule"] == "30 10 * * 1-5"
|
||||
assert body["timezone"] == "Europe/London"
|
||||
assert body["delivery_channel"] == "email"
|
||||
assert body["delivery_target"] == "person@example.com"
|
||||
assert body["created_at"] == original_created_at
|
||||
assert body["last_run_at"] == 1_700_000_100_000
|
||||
assert body["status"] == "completed"
|
||||
|
||||
saved = store.get("editable")
|
||||
assert saved is not None
|
||||
assert saved.created_at == original_created_at
|
||||
assert saved.last_run_at == 1_700_000_100_000
|
||||
assert saved.delivery.status.value == "none"
|
||||
|
||||
|
||||
def test_patch_same_cadence_and_delivery_preserves_next_run_and_receipt(
|
||||
client: TestClient, store: ScheduledResearchJobStore
|
||||
):
|
||||
_seed(
|
||||
store,
|
||||
id="stable",
|
||||
prompt="old prompt",
|
||||
schedule="0 9 * * 1-5",
|
||||
timezone="UTC",
|
||||
next_run_at=1_900_000_000_000,
|
||||
delivery_channel="email",
|
||||
delivery_target="person@example.com",
|
||||
delivery=DeliveryRecord(
|
||||
status=DeliveryStatus.SENT,
|
||||
provider_message_id="provider-1",
|
||||
),
|
||||
)
|
||||
|
||||
response = client.patch(
|
||||
"/scheduled-runs/stable",
|
||||
json={
|
||||
"prompt": "new prompt",
|
||||
"schedule": "0 9 * * 1-5",
|
||||
"timezone": "UTC",
|
||||
"delivery_channel": "email",
|
||||
"delivery_target": "person@example.com",
|
||||
"delivery_target_ref": None,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["next_run_at"] == 1_900_000_000_000
|
||||
assert body["delivery_status"] == "sent"
|
||||
assert body["delivery_provider_message_id"] == "provider-1"
|
||||
|
||||
|
||||
def test_patch_interval_timezone_only_preserves_next_run(
|
||||
client: TestClient, store: ScheduledResearchJobStore
|
||||
):
|
||||
_seed(
|
||||
store,
|
||||
id="interval-timezone",
|
||||
schedule="60000",
|
||||
timezone="UTC",
|
||||
next_run_at=1_900_000_000_000,
|
||||
)
|
||||
|
||||
response = client.patch(
|
||||
"/scheduled-runs/interval-timezone",
|
||||
json={"timezone": "America/New_York"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["timezone"] == "America/New_York"
|
||||
assert body["next_run_at"] == 1_900_000_000_000
|
||||
|
||||
saved = store.get("interval-timezone")
|
||||
assert saved is not None
|
||||
assert saved.timezone == "America/New_York"
|
||||
assert saved.next_run_at == 1_900_000_000_000
|
||||
|
||||
|
||||
def test_patch_rejects_incomplete_delivery_target(
|
||||
client: TestClient, store: ScheduledResearchJobStore
|
||||
):
|
||||
_seed(store, id="editable")
|
||||
response = client.patch(
|
||||
"/scheduled-runs/editable",
|
||||
json={"delivery_channel": "email", "delivery_target": None},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert "delivery_target" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_patch_unknown_job_returns_404(client: TestClient):
|
||||
response = client.patch("/scheduled-runs/missing", json={"prompt": "updated"})
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_patch_running_job_returns_409(
|
||||
client: TestClient, store: ScheduledResearchJobStore
|
||||
):
|
||||
_seed(store, id="running", status=JobStatus.RUNNING)
|
||||
response = client.patch("/scheduled-runs/running", json={"prompt": "updated"})
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
def test_delete_removes_job_and_returns_204(
|
||||
client: TestClient, store: ScheduledResearchJobStore
|
||||
):
|
||||
|
||||
@@ -143,6 +143,23 @@
|
||||
"advancedHint": "تعبير cron من 5 حقول يُقيّم في المنطقة الزمنية المحددة (دقيقة ساعة يوم شهر يوم-أسبوع؛ تُدعم القوائم والنطاقات)، أو فاصل زمني بالمللي ثانية.",
|
||||
"timezoneLabel": "المنطقة الزمنية",
|
||||
"create": "إنشاء جدولة",
|
||||
"saveChanges": "حفظ التغييرات",
|
||||
"cancelEdit": "إلغاء التعديل",
|
||||
"editingHint": "أنت تعدّل هذا التشغيل المجدول. يحافظ الحفظ على هويته وسجل التنفيذ.",
|
||||
"edit": "تعديل",
|
||||
"editAria": "تعديل التشغيل المجدول: {{prompt}}",
|
||||
"editRunningDisabled": "انتظر حتى ينتهي هذا التشغيل قبل تعديله.",
|
||||
"deliveryInApp": "الاحتفاظ في Vibe فقط (عدم الإرسال)",
|
||||
"deliveryNoChannels": "لا توجد قنوات تسليم مفعّلة ومهيأة. هيّئ واحدة في الإعدادات > القنوات.",
|
||||
"deliveryChannelsLoadFailed": "تعذّر تحميل قنوات التسليم المهيأة.",
|
||||
"deliveryChannelUnavailable": "{{channel}} (غير متاح حاليًا)",
|
||||
"deliveryTargetKind_destination": "الوجهة",
|
||||
"deliveryTargetKind_email_address": "عنوان البريد الإلكتروني للمستلم",
|
||||
"deliveryTargetKind_chat": "المحادثة",
|
||||
"deliveryTargetKind_channel": "القناة",
|
||||
"deliveryTargetDisabled": "اختر قناة تسليم أولًا.",
|
||||
"deliverySummary": "{{channel}} → {{target}}",
|
||||
"deliveryUnknownTarget": "وجهة مهيأة",
|
||||
"promptRequired": "أدخل موجّه البحث قبل إنشاء الجدولة.",
|
||||
"invalidTime": "أدخل وقتًا صالحًا بصيغة HH:MM.",
|
||||
"executorHint": "تُنفّذ المهام فقط عندما يعمل الخادم مع تفعيل VIBE_TRADING_ENABLE_SCHEDULER.",
|
||||
@@ -197,7 +214,10 @@
|
||||
"verdictUnreadable": "تعذر قراءة أحدث تقييم",
|
||||
"verdictNoCalls": "لا إشارات",
|
||||
"verdictDelta": "{{was}} ← {{now}}",
|
||||
"verdictRecorded": "اعتبارًا من {{when}}"
|
||||
"verdictRecorded": "اعتبارًا من {{when}}",
|
||||
"deliveryKnownTargetLabel": "وجهة معروفة",
|
||||
"deliveryKnownTargetPlaceholder": "اختر وجهة معروفة…",
|
||||
"deliverySuggestionKind_private_chat": "محادثتي الخاصة"
|
||||
},
|
||||
"reports": {
|
||||
"badge": "التقارير",
|
||||
@@ -2086,4 +2106,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +148,23 @@
|
||||
"advancedHint": "Ein 5-Feld-Cron-Ausdruck, ausgewertet in der gewählten Zeitzone (Minute Stunde Tag Monat Wochentag; Listen und Bereiche erlaubt), oder ein reines Intervall in Millisekunden.",
|
||||
"timezoneLabel": "Zeitzone",
|
||||
"create": "Lauf planen",
|
||||
"saveChanges": "Änderungen speichern",
|
||||
"cancelEdit": "Bearbeitung abbrechen",
|
||||
"editingHint": "Dieser geplante Lauf wird bearbeitet. Beim Speichern bleiben Identität und Ausführungsverlauf erhalten.",
|
||||
"edit": "Bearbeiten",
|
||||
"editAria": "Geplanten Lauf bearbeiten: {{prompt}}",
|
||||
"editRunningDisabled": "Warten Sie, bis dieser Lauf beendet ist, bevor Sie ihn bearbeiten.",
|
||||
"deliveryInApp": "Nur in Vibe behalten (nicht senden)",
|
||||
"deliveryNoChannels": "Es sind keine aktivierten Zustellungskanäle konfiguriert. Konfigurieren Sie einen unter Einstellungen → Kanäle.",
|
||||
"deliveryChannelsLoadFailed": "Konfigurierte Zustellungskanäle konnten nicht geladen werden.",
|
||||
"deliveryChannelUnavailable": "{{channel}} (derzeit nicht verfügbar)",
|
||||
"deliveryTargetKind_destination": "Ziel",
|
||||
"deliveryTargetKind_email_address": "E-Mail-Adresse des Empfängers",
|
||||
"deliveryTargetKind_chat": "Chat",
|
||||
"deliveryTargetKind_channel": "Kanal",
|
||||
"deliveryTargetDisabled": "Wählen Sie zuerst einen Zustellungskanal.",
|
||||
"deliverySummary": "{{channel}} → {{target}}",
|
||||
"deliveryUnknownTarget": "Konfiguriertes Ziel",
|
||||
"promptRequired": "Geben Sie einen Recherche-Prompt ein, bevor Sie planen.",
|
||||
"invalidTime": "Geben Sie eine gültige Zeit als HH:MM ein.",
|
||||
"executorHint": "Läufe werden nur ausgeführt, wenn der Server mit aktiviertem VIBE_TRADING_ENABLE_SCHEDULER gestartet wurde.",
|
||||
@@ -202,7 +219,10 @@
|
||||
"verdictUnreadable": "Letztes Urteil nicht lesbar",
|
||||
"verdictNoCalls": "Keine Calls",
|
||||
"verdictDelta": "{{was}} → {{now}}",
|
||||
"verdictRecorded": "Stand {{when}}"
|
||||
"verdictRecorded": "Stand {{when}}",
|
||||
"deliveryKnownTargetLabel": "Bekanntes Ziel",
|
||||
"deliveryKnownTargetPlaceholder": "Bekanntes Ziel auswählen…",
|
||||
"deliverySuggestionKind_private_chat": "Privater Chat"
|
||||
},
|
||||
"reports": {
|
||||
"badge": "Berichte",
|
||||
@@ -2086,4 +2106,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +148,9 @@
|
||||
"advancedHint": "A 5-field cron expression evaluated in the selected timezone (minute hour day month weekday; lists and ranges allowed), or a bare interval in milliseconds.",
|
||||
"timezoneLabel": "Timezone",
|
||||
"create": "Schedule run",
|
||||
"saveChanges": "Save changes",
|
||||
"cancelEdit": "Cancel editing",
|
||||
"editingHint": "Editing this scheduled run. Saving keeps its identity and execution history.",
|
||||
"promptRequired": "Enter a research prompt before scheduling.",
|
||||
"invalidTime": "Enter a valid time as HH:MM.",
|
||||
"executorHint": "Runs execute only when the server is started with VIBE_TRADING_ENABLE_SCHEDULER enabled.",
|
||||
@@ -182,16 +185,30 @@
|
||||
"proposalConfirmCancel": "Confirm cancellation",
|
||||
"nextRun": "Next run: {{when}}",
|
||||
"lastError": "Last error: {{error}}",
|
||||
"edit": "Edit",
|
||||
"editAria": "Edit scheduled run: {{prompt}}",
|
||||
"editRunningDisabled": "Wait for this run to finish before editing it.",
|
||||
"delete": "Delete",
|
||||
"deleteAria": "Delete scheduled run: {{prompt}}",
|
||||
"confirmDelete": "Confirm delete",
|
||||
"confirmDeleteAria": "Confirm deleting scheduled run: {{prompt}}",
|
||||
"deliveryChannelLabel": "Deliver to channel",
|
||||
"deliveryChannelPlaceholder": "telegram, feishu, … (leave empty to keep results in the app)",
|
||||
"deliveryTargetLabel": "Channel target",
|
||||
"deliveryTargetPlaceholder": "chat / group / user id",
|
||||
"deliveryHint": "Off by default. A briefing is pushed once the run finishes, not when it is scheduled.",
|
||||
"deliveryTargetRequired": "Choose a target for that channel, or clear the channel.",
|
||||
"deliveryChannelLabel": "Delivery channel",
|
||||
"deliveryChannelPlaceholder": "Choose a configured channel",
|
||||
"deliveryInApp": "Keep in Vibe only (do not send)",
|
||||
"deliveryNoChannels": "No enabled delivery channels are configured. Configure one in Settings → Channels.",
|
||||
"deliveryChannelsLoadFailed": "Could not load configured delivery channels.",
|
||||
"deliveryChannelUnavailable": "{{channel}} (currently unavailable)",
|
||||
"deliveryTargetLabel": "Destination",
|
||||
"deliveryTargetKind_destination": "Destination",
|
||||
"deliveryTargetKind_email_address": "Recipient email address",
|
||||
"deliveryTargetKind_chat": "Chat",
|
||||
"deliveryTargetKind_channel": "Channel",
|
||||
"deliveryTargetPlaceholder": "Chat, group, user, address, or channel ID",
|
||||
"deliveryTargetDisabled": "Choose a delivery channel first.",
|
||||
"deliveryHint": "A briefing is pushed once the run finishes, not when it is scheduled.",
|
||||
"deliveryTargetRequired": "Choose a target for that channel, or keep delivery in Vibe only.",
|
||||
"deliverySummary": "{{channel}} → {{target}}",
|
||||
"deliveryUnknownTarget": "Configured destination",
|
||||
"delivery_none": "Delivering to {{channel}} after the next run",
|
||||
"delivery_pending": "Briefing owed to {{channel}}",
|
||||
"delivery_sending": "Sending the briefing to {{channel}}",
|
||||
@@ -202,7 +219,10 @@
|
||||
"verdictUnreadable": "Latest verdict unreadable",
|
||||
"verdictNoCalls": "No calls",
|
||||
"verdictDelta": "{{was}} → {{now}}",
|
||||
"verdictRecorded": "as of {{when}}"
|
||||
"verdictRecorded": "as of {{when}}",
|
||||
"deliveryKnownTargetLabel": "Known destination",
|
||||
"deliveryKnownTargetPlaceholder": "Choose a known destination…",
|
||||
"deliverySuggestionKind_private_chat": "Private chat"
|
||||
},
|
||||
"reports": {
|
||||
"badge": "Reports",
|
||||
@@ -2086,4 +2106,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +148,9 @@
|
||||
"advancedHint": "Una expresión cron de 5 campos evaluada en la zona horaria seleccionada (minuto hora día mes día_de_semana; se permiten listas y rangos), o un intervalo simple en milisegundos.",
|
||||
"timezoneLabel": "Zona horaria",
|
||||
"create": "Programar ejecución",
|
||||
"saveChanges": "Guardar cambios",
|
||||
"cancelEdit": "Cancelar edición",
|
||||
"editingHint": "Estás editando esta ejecución programada. Al guardar se conservan su identidad y su historial de ejecución.",
|
||||
"promptRequired": "Ingresa un prompt de investigación antes de programar.",
|
||||
"invalidTime": "Ingresa una hora válida en formato HH:MM.",
|
||||
"executorHint": "Las ejecuciones solo funcionan cuando el servidor se inicia con VIBE_TRADING_ENABLE_SCHEDULER habilitado.",
|
||||
@@ -182,16 +185,30 @@
|
||||
"proposalConfirmCancel": "Confirmar cancelación",
|
||||
"nextRun": "Próxima ejecución: {{when}}",
|
||||
"lastError": "Último error: {{error}}",
|
||||
"edit": "Editar",
|
||||
"editAria": "Editar ejecución programada: {{prompt}}",
|
||||
"editRunningDisabled": "Espera a que termine esta ejecución antes de editarla.",
|
||||
"delete": "Eliminar",
|
||||
"deleteAria": "Eliminar ejecución programada: {{prompt}}",
|
||||
"confirmDelete": "Confirmar eliminación",
|
||||
"confirmDeleteAria": "Confirmar eliminación de ejecución programada: {{prompt}}",
|
||||
"deliveryChannelLabel": "Enviar al canal",
|
||||
"deliveryChannelPlaceholder": "telegram, feishu… (vacío para que los resultados queden en la app)",
|
||||
"deliveryTargetLabel": "Destino en el canal",
|
||||
"deliveryTargetPlaceholder": "id de chat / grupo / usuario",
|
||||
"deliveryHint": "Desactivado por defecto. El informe se envía **cuando la ejecución termina**, no cuando se programa.",
|
||||
"deliveryTargetRequired": "Indica un destino para ese canal, o deja el canal vacío.",
|
||||
"deliveryChannelLabel": "Canal de entrega",
|
||||
"deliveryChannelPlaceholder": "Elige un canal configurado",
|
||||
"deliveryInApp": "Solo en Vibe (no enviar)",
|
||||
"deliveryNoChannels": "No hay canales de entrega habilitados configurados. Configura uno en Configuración → Canales.",
|
||||
"deliveryChannelsLoadFailed": "No se pudieron cargar los canales de entrega configurados.",
|
||||
"deliveryChannelUnavailable": "{{channel}} (no disponible actualmente)",
|
||||
"deliveryTargetLabel": "Destino",
|
||||
"deliveryTargetKind_destination": "Destino",
|
||||
"deliveryTargetKind_email_address": "Dirección de email del destinatario",
|
||||
"deliveryTargetKind_chat": "Chat",
|
||||
"deliveryTargetKind_channel": "Canal",
|
||||
"deliveryTargetPlaceholder": "chat, grupo, usuario, dirección o ID de canal",
|
||||
"deliveryTargetDisabled": "Primero elige un canal de entrega.",
|
||||
"deliveryHint": "El informe se envía cuando la ejecución termina, no cuando se programa.",
|
||||
"deliveryTargetRequired": "Indica un destino para ese canal, o deja la entrega solo en Vibe.",
|
||||
"deliverySummary": "{{channel}} → {{target}}",
|
||||
"deliveryUnknownTarget": "Destino configurado",
|
||||
"delivery_none": "Se enviará a {{channel}} tras la próxima ejecución",
|
||||
"delivery_pending": "Informe pendiente de envío a {{channel}}",
|
||||
"delivery_sending": "Enviando el informe a {{channel}}",
|
||||
@@ -202,7 +219,10 @@
|
||||
"verdictUnreadable": "No se pudo leer el último veredicto",
|
||||
"verdictNoCalls": "Sin señales",
|
||||
"verdictDelta": "{{was}} → {{now}}",
|
||||
"verdictRecorded": "a fecha de {{when}}"
|
||||
"verdictRecorded": "a fecha de {{when}}",
|
||||
"deliveryKnownTargetLabel": "Destino conocido",
|
||||
"deliveryKnownTargetPlaceholder": "Elegir un destino conocido…",
|
||||
"deliverySuggestionKind_private_chat": "Mi chat privado"
|
||||
},
|
||||
"reports": {
|
||||
"badge": "Informes",
|
||||
@@ -2086,4 +2106,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +148,23 @@
|
||||
"advancedHint": "Ekspresi cron 5 kolom yang dievaluasi dalam zona waktu yang dipilih (menit jam hari bulan hari-dalam-minggu; daftar dan rentang diperbolehkan), atau interval langsung dalam milidetik.",
|
||||
"timezoneLabel": "Zona waktu",
|
||||
"create": "Jadwalkan run",
|
||||
"saveChanges": "Simpan perubahan",
|
||||
"cancelEdit": "Batalkan pengeditan",
|
||||
"editingHint": "Anda sedang mengedit proses terjadwal ini. Penyimpanan mempertahankan identitas dan riwayat eksekusinya.",
|
||||
"edit": "Edit",
|
||||
"editAria": "Edit proses terjadwal: {{prompt}}",
|
||||
"editRunningDisabled": "Tunggu hingga proses ini selesai sebelum mengeditnya.",
|
||||
"deliveryInApp": "Simpan hanya di Vibe (jangan kirim)",
|
||||
"deliveryNoChannels": "Tidak ada saluran pengiriman aktif yang dikonfigurasi. Konfigurasikan satu di Pengaturan → Saluran.",
|
||||
"deliveryChannelsLoadFailed": "Tidak dapat memuat saluran pengiriman yang dikonfigurasi.",
|
||||
"deliveryChannelUnavailable": "{{channel}} (saat ini tidak tersedia)",
|
||||
"deliveryTargetKind_destination": "Tujuan",
|
||||
"deliveryTargetKind_email_address": "Alamat email penerima",
|
||||
"deliveryTargetKind_chat": "Chat",
|
||||
"deliveryTargetKind_channel": "Saluran",
|
||||
"deliveryTargetDisabled": "Pilih saluran pengiriman terlebih dahulu.",
|
||||
"deliverySummary": "{{channel}} → {{target}}",
|
||||
"deliveryUnknownTarget": "Tujuan yang dikonfigurasi",
|
||||
"promptRequired": "Masukkan prompt riset sebelum menjadwalkan.",
|
||||
"invalidTime": "Masukkan waktu yang valid dalam format HH:MM.",
|
||||
"executorHint": "Run hanya dieksekusi jika server dijalankan dengan VIBE_TRADING_ENABLE_SCHEDULER diaktifkan.",
|
||||
@@ -202,7 +219,10 @@
|
||||
"verdictUnreadable": "Kesimpulan terbaru tidak dapat dibaca",
|
||||
"verdictNoCalls": "Tidak ada sinyal",
|
||||
"verdictDelta": "{{was}} → {{now}}",
|
||||
"verdictRecorded": "per {{when}}"
|
||||
"verdictRecorded": "per {{when}}",
|
||||
"deliveryKnownTargetLabel": "Tujuan dikenal",
|
||||
"deliveryKnownTargetPlaceholder": "Pilih tujuan yang dikenal…",
|
||||
"deliverySuggestionKind_private_chat": "Chat pribadi saya"
|
||||
},
|
||||
"reports": {
|
||||
"badge": "Laporan",
|
||||
@@ -2086,4 +2106,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +143,23 @@
|
||||
"advancedHint": "選択したタイムゾーンで評価される 5 フィールドの cron 式(分 時 日 月 曜日;リストと範囲に対応)、またはミリ秒単位の間隔。",
|
||||
"timezoneLabel": "タイムゾーン",
|
||||
"create": "スケジュールを作成",
|
||||
"saveChanges": "変更を保存",
|
||||
"cancelEdit": "編集をキャンセル",
|
||||
"editingHint": "このスケジュール実行を編集中です。保存しても識別子と実行履歴は保持されます。",
|
||||
"edit": "編集",
|
||||
"editAria": "スケジュール実行を編集: {{prompt}}",
|
||||
"editRunningDisabled": "この実行が完了してから編集してください。",
|
||||
"deliveryInApp": "Vibe内のみに保持(送信しない)",
|
||||
"deliveryNoChannels": "有効な配信チャネルが設定されていません。設定 → チャネルで設定してください。",
|
||||
"deliveryChannelsLoadFailed": "設定済みの配信チャネルを読み込めませんでした。",
|
||||
"deliveryChannelUnavailable": "{{channel}}(現在利用できません)",
|
||||
"deliveryTargetKind_destination": "送信先",
|
||||
"deliveryTargetKind_email_address": "受信者のメールアドレス",
|
||||
"deliveryTargetKind_chat": "チャット",
|
||||
"deliveryTargetKind_channel": "チャネル",
|
||||
"deliveryTargetDisabled": "先に配信チャネルを選択してください。",
|
||||
"deliverySummary": "{{channel}} → {{target}}",
|
||||
"deliveryUnknownTarget": "設定済みの送信先",
|
||||
"promptRequired": "スケジュール作成の前にリサーチプロンプトを入力してください。",
|
||||
"invalidTime": "有効な時刻を HH:MM 形式で入力してください。",
|
||||
"executorHint": "サーバーで VIBE_TRADING_ENABLE_SCHEDULER が有効な場合のみ実行されます。",
|
||||
@@ -197,7 +214,10 @@
|
||||
"verdictUnreadable": "最新の判定を読み取れません",
|
||||
"verdictNoCalls": "コールなし",
|
||||
"verdictDelta": "{{was}} → {{now}}",
|
||||
"verdictRecorded": "{{when}} 時点"
|
||||
"verdictRecorded": "{{when}} 時点",
|
||||
"deliveryKnownTargetLabel": "既知の送信先",
|
||||
"deliveryKnownTargetPlaceholder": "既知の送信先を選択…",
|
||||
"deliverySuggestionKind_private_chat": "自分のプライベートチャット"
|
||||
},
|
||||
"reports": {
|
||||
"badge": "レポート",
|
||||
@@ -2086,4 +2106,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +143,23 @@
|
||||
"advancedHint": "선택한 시간대 기준으로 평가되는 5필드 cron 표현식(분 시 일 월 요일; 목록과 범위 지원) 또는 밀리초 단위 간격.",
|
||||
"timezoneLabel": "시간대",
|
||||
"create": "예약 생성",
|
||||
"saveChanges": "변경 사항 저장",
|
||||
"cancelEdit": "편집 취소",
|
||||
"editingHint": "이 예약 실행을 편집 중입니다. 저장해도 식별 정보와 실행 기록은 유지됩니다.",
|
||||
"edit": "편집",
|
||||
"editAria": "예약 실행 편집: {{prompt}}",
|
||||
"editRunningDisabled": "이 실행이 끝난 후 편집하세요.",
|
||||
"deliveryInApp": "Vibe에만 보관(전송 안 함)",
|
||||
"deliveryNoChannels": "활성화된 전송 채널이 구성되어 있지 않습니다. 설정 → 채널에서 하나를 구성하세요.",
|
||||
"deliveryChannelsLoadFailed": "구성된 전송 채널을 불러오지 못했습니다.",
|
||||
"deliveryChannelUnavailable": "{{channel}} (현재 사용할 수 없음)",
|
||||
"deliveryTargetKind_destination": "대상",
|
||||
"deliveryTargetKind_email_address": "수신자 이메일 주소",
|
||||
"deliveryTargetKind_chat": "채팅",
|
||||
"deliveryTargetKind_channel": "채널",
|
||||
"deliveryTargetDisabled": "먼저 전송 채널을 선택하세요.",
|
||||
"deliverySummary": "{{channel}} → {{target}}",
|
||||
"deliveryUnknownTarget": "구성된 대상",
|
||||
"promptRequired": "예약을 만들기 전에 리서치 프롬프트를 입력하세요.",
|
||||
"invalidTime": "유효한 시각을 HH:MM 형식으로 입력하세요.",
|
||||
"executorHint": "서버에서 VIBE_TRADING_ENABLE_SCHEDULER가 활성화된 경우에만 실행됩니다.",
|
||||
@@ -197,7 +214,10 @@
|
||||
"verdictUnreadable": "최신 판정을 읽을 수 없음",
|
||||
"verdictNoCalls": "호출 없음",
|
||||
"verdictDelta": "{{was}} → {{now}}",
|
||||
"verdictRecorded": "{{when}} 기준"
|
||||
"verdictRecorded": "{{when}} 기준",
|
||||
"deliveryKnownTargetLabel": "알려진 대상",
|
||||
"deliveryKnownTargetPlaceholder": "알려진 대상 선택…",
|
||||
"deliverySuggestionKind_private_chat": "내 비공개 채팅"
|
||||
},
|
||||
"reports": {
|
||||
"badge": "리포트",
|
||||
@@ -2086,4 +2106,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +148,23 @@
|
||||
"advancedHint": "Uma expressão cron de 5 campos avaliada no fuso horário selecionado (minuto hora dia mês dia da semana; listas e intervalos permitidos) ou um intervalo simples em milissegundos.",
|
||||
"timezoneLabel": "Fuso horário",
|
||||
"create": "Agendar execução",
|
||||
"saveChanges": "Salvar alterações",
|
||||
"cancelEdit": "Cancelar edição",
|
||||
"editingHint": "Você está editando esta execução agendada. Ao salvar, a identidade e o histórico de execuções são preservados.",
|
||||
"edit": "Editar",
|
||||
"editAria": "Editar execução agendada: {{prompt}}",
|
||||
"editRunningDisabled": "Aguarde esta execução terminar antes de editá-la.",
|
||||
"deliveryInApp": "Manter apenas no Vibe (não enviar)",
|
||||
"deliveryNoChannels": "Nenhum canal de entrega habilitado está configurado. Configure um em Configurações → Canais.",
|
||||
"deliveryChannelsLoadFailed": "Não foi possível carregar os canais de entrega configurados.",
|
||||
"deliveryChannelUnavailable": "{{channel}} (indisponível no momento)",
|
||||
"deliveryTargetKind_destination": "Destino",
|
||||
"deliveryTargetKind_email_address": "Endereço de e-mail do destinatário",
|
||||
"deliveryTargetKind_chat": "Chat",
|
||||
"deliveryTargetKind_channel": "Canal",
|
||||
"deliveryTargetDisabled": "Escolha primeiro um canal de entrega.",
|
||||
"deliverySummary": "{{channel}} → {{target}}",
|
||||
"deliveryUnknownTarget": "Destino configurado",
|
||||
"promptRequired": "Insira um prompt de pesquisa antes de agendar.",
|
||||
"invalidTime": "Insira um horário válido como HH:MM.",
|
||||
"executorHint": "As execuções são executadas somente quando o servidor é iniciado com VIBE_TRADING_ENABLE_SCHEDULER ativado.",
|
||||
@@ -202,7 +219,10 @@
|
||||
"verdictUnreadable": "Último veredicto ilegível",
|
||||
"verdictNoCalls": "Sem chamadas",
|
||||
"verdictDelta": "{{was}} → {{now}}",
|
||||
"verdictRecorded": "a partir de {{when}}"
|
||||
"verdictRecorded": "a partir de {{when}}",
|
||||
"deliveryKnownTargetLabel": "Destino conhecido",
|
||||
"deliveryKnownTargetPlaceholder": "Escolher um destino conhecido…",
|
||||
"deliverySuggestionKind_private_chat": "Meu chat privado"
|
||||
},
|
||||
"reports": {
|
||||
"badge": "Relatórios",
|
||||
@@ -2086,4 +2106,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +143,23 @@
|
||||
"advancedHint": "在所选时区求值的 5 字段 cron 表达式(分 时 日 月 星期;支持列表和范围),或以毫秒为单位的间隔。",
|
||||
"timezoneLabel": "时区",
|
||||
"create": "创建定时任务",
|
||||
"saveChanges": "保存更改",
|
||||
"cancelEdit": "取消编辑",
|
||||
"editingHint": "正在编辑此定时任务。保存后将保留其标识和执行历史。",
|
||||
"edit": "编辑",
|
||||
"editAria": "编辑定时任务:{{prompt}}",
|
||||
"editRunningDisabled": "请等待此任务执行完成后再编辑。",
|
||||
"deliveryInApp": "仅保留在 Vibe 中(不发送)",
|
||||
"deliveryNoChannels": "尚未配置已启用的投递渠道。请在“设置 → 渠道”中配置一个。",
|
||||
"deliveryChannelsLoadFailed": "无法加载已配置的投递渠道。",
|
||||
"deliveryChannelUnavailable": "{{channel}}(当前不可用)",
|
||||
"deliveryTargetKind_destination": "目标",
|
||||
"deliveryTargetKind_email_address": "收件人邮箱地址",
|
||||
"deliveryTargetKind_chat": "聊天",
|
||||
"deliveryTargetKind_channel": "频道",
|
||||
"deliveryTargetDisabled": "请先选择投递渠道。",
|
||||
"deliverySummary": "{{channel}} → {{target}}",
|
||||
"deliveryUnknownTarget": "已配置目标",
|
||||
"promptRequired": "请先填写研究提示词再创建定时任务。",
|
||||
"invalidTime": "请输入有效时间(格式 HH:MM)。",
|
||||
"executorHint": "仅当服务端启用 VIBE_TRADING_ENABLE_SCHEDULER 时才会执行。",
|
||||
@@ -197,7 +214,10 @@
|
||||
"verdictUnreadable": "最新判定无法解析",
|
||||
"verdictNoCalls": "无调用",
|
||||
"verdictDelta": "{{was}} → {{now}}",
|
||||
"verdictRecorded": "记于 {{when}}"
|
||||
"verdictRecorded": "记于 {{when}}",
|
||||
"deliveryKnownTargetLabel": "已知目标",
|
||||
"deliveryKnownTargetPlaceholder": "选择已知目标…",
|
||||
"deliverySuggestionKind_private_chat": "我的私聊"
|
||||
},
|
||||
"reports": {
|
||||
"badge": "报告",
|
||||
@@ -2086,4 +2106,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,6 +464,11 @@ export const api = {
|
||||
listScheduledRuns: (signal?: AbortSignal) => request<ScheduledRun[]>("/scheduled-runs", { signal }),
|
||||
createScheduledRun: (body: CreateScheduledRunRequest) =>
|
||||
request<ScheduledRun>("/scheduled-runs", { method: "POST", body: JSON.stringify(body) }),
|
||||
updateScheduledRun: (id: string, body: UpdateScheduledRunRequest) =>
|
||||
request<ScheduledRun>(`/scheduled-runs/${encodeURIComponent(id)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
deleteScheduledRun: (id: string) =>
|
||||
request<void>(`/scheduled-runs/${encodeURIComponent(id)}`, { method: "DELETE" }),
|
||||
commitScheduledResearchProposal: (proposalId: string) =>
|
||||
@@ -709,6 +714,18 @@ export interface CreateScheduledRunRequest {
|
||||
delivery_target_ref?: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateScheduledRunRequest {
|
||||
title?: string | null;
|
||||
prompt?: string;
|
||||
schedule?: string;
|
||||
timezone?: string | null;
|
||||
end_at?: number | null;
|
||||
config?: Record<string, unknown> | null;
|
||||
delivery_channel?: string | null;
|
||||
delivery_target?: string | null;
|
||||
delivery_target_ref?: string | null;
|
||||
}
|
||||
|
||||
export interface ScheduledResearchProposalJob {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -852,6 +869,12 @@ export interface UpdateDataSourceSettingsRequest {
|
||||
source_orders?: SourceOrderUpdate[];
|
||||
}
|
||||
|
||||
export interface DeliveryTargetSuggestion {
|
||||
target: string;
|
||||
kind?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface ChannelAdapterStatus {
|
||||
name: string;
|
||||
display_name: string;
|
||||
@@ -862,6 +885,11 @@ export interface ChannelAdapterStatus {
|
||||
running: boolean;
|
||||
error?: string;
|
||||
install_hint?: string;
|
||||
delivery_target_label?: string;
|
||||
delivery_target_kind?: string;
|
||||
delivery_target_placeholder?: string;
|
||||
delivery_target_input_type?: string;
|
||||
delivery_target_suggestions?: DeliveryTargetSuggestion[];
|
||||
}
|
||||
|
||||
export interface ChannelRuntimeStatus {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CalendarClock, Loader2, Plus, Trash2 } from "lucide-react";
|
||||
import { CalendarClock, Loader2, Pencil, Plus, Save, Trash2, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api, ApiError, type ScheduledRun } from "@/lib/api";
|
||||
import { api, ApiError, type ChannelAdapterStatus, type ScheduledRun } from "@/lib/api";
|
||||
import {
|
||||
describeCadence,
|
||||
formatIntervalMs,
|
||||
@@ -20,6 +20,13 @@ const POLL_MS = 15_000;
|
||||
type DaysChoice = "every" | "weekdays";
|
||||
type ComposerMode = "time" | "advanced";
|
||||
|
||||
function isWeekdayCadence(weekdays: number[]): boolean {
|
||||
return (
|
||||
weekdays.length === 5 &&
|
||||
weekdays.every((day, index) => day === index + 1)
|
||||
);
|
||||
}
|
||||
|
||||
function browserTimezone(): string {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
||||
@@ -28,6 +35,15 @@ function browserTimezone(): string {
|
||||
}
|
||||
}
|
||||
|
||||
function maskDeliveryTarget(target: string): string {
|
||||
if (target.length <= 2) return "•".repeat(target.length);
|
||||
if (target.length <= 6) {
|
||||
return `${target.slice(0, 1)}${"•".repeat(target.length - 2)}${target.slice(-1)}`;
|
||||
}
|
||||
const prefixLength = target.startsWith("-100") ? 4 : 3;
|
||||
return `${target.slice(0, prefixLength)}••••${target.slice(-2)}`;
|
||||
}
|
||||
|
||||
function timezoneOptions(): string[] {
|
||||
// Not yet in the project's TS lib target, hence the local type.
|
||||
const supported = (Intl as { supportedValuesOf?: (key: "timeZone") => string[] })
|
||||
@@ -80,6 +96,9 @@ export function Scheduled() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [listError, setListError] = useState<string | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<string | null>(null);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [channelStatus, setChannelStatus] = useState<Record<string, ChannelAdapterStatus>>({});
|
||||
const [channelLoadError, setChannelLoadError] = useState(false);
|
||||
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [mode, setMode] = useState<ComposerMode>("time");
|
||||
@@ -91,6 +110,7 @@ export function Scheduled() {
|
||||
// it means the briefing stays in the app.
|
||||
const [deliveryChannel, setDeliveryChannel] = useState("");
|
||||
const [deliveryTarget, setDeliveryTarget] = useState("");
|
||||
const [deliveryTargetRef, setDeliveryTargetRef] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [composerError, setComposerError] = useState<string | null>(null);
|
||||
|
||||
@@ -131,6 +151,24 @@ export function Scheduled() {
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void api.getChannelStatus()
|
||||
.then((status) => {
|
||||
if (cancelled) return;
|
||||
setChannelStatus(status.channels ?? {});
|
||||
setChannelLoadError(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setChannelStatus({});
|
||||
setChannelLoadError(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-disarm an armed delete after a few seconds (blur is unreliable on
|
||||
// Safari/iOS, where buttons do not take focus on click).
|
||||
useEffect(() => {
|
||||
@@ -149,7 +187,49 @@ export function Scheduled() {
|
||||
return `${Number(match[2])} ${Number(match[1])} * * ${days === "every" ? "*" : "1-5"}`;
|
||||
}
|
||||
|
||||
async function handleCreate(event: React.FormEvent) {
|
||||
function resetComposer() {
|
||||
setEditingId(null);
|
||||
setPrompt("");
|
||||
setMode("time");
|
||||
setTime("09:00");
|
||||
setDays("weekdays");
|
||||
setAdvanced("");
|
||||
setTimezone(browserTimezone());
|
||||
setDeliveryChannel("");
|
||||
setDeliveryTarget("");
|
||||
setDeliveryTargetRef(null);
|
||||
setComposerError(null);
|
||||
}
|
||||
|
||||
function beginEdit(run: ScheduledRun) {
|
||||
const cadence = describeCadence(run.schedule);
|
||||
setPendingDelete(null);
|
||||
setEditingId(run.id);
|
||||
setPrompt(run.prompt);
|
||||
setTimezone(displayZone(run));
|
||||
setDeliveryChannel(run.delivery_channel ?? "");
|
||||
setDeliveryTarget(run.delivery_target ?? "");
|
||||
setDeliveryTargetRef(run.delivery_target_ref);
|
||||
setComposerError(null);
|
||||
|
||||
if (cadence.kind === "daily") {
|
||||
setMode("time");
|
||||
setDays("every");
|
||||
setTime(formatWallTime(cadence.hour, cadence.minute));
|
||||
setAdvanced("");
|
||||
} else if (cadence.kind === "weekly" && isWeekdayCadence(cadence.weekdays)) {
|
||||
setMode("time");
|
||||
setDays("weekdays");
|
||||
setTime(formatWallTime(cadence.hour, cadence.minute));
|
||||
setAdvanced("");
|
||||
} else {
|
||||
setMode("advanced");
|
||||
setAdvanced(run.schedule);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setComposerError(null);
|
||||
if (!prompt.trim()) {
|
||||
@@ -169,14 +249,20 @@ export function Scheduled() {
|
||||
setComposerError(t("scheduled.deliveryTargetRequired"));
|
||||
return;
|
||||
}
|
||||
await api.createScheduledRun({
|
||||
const payload = {
|
||||
prompt: prompt.trim(),
|
||||
schedule,
|
||||
timezone,
|
||||
delivery_channel: channel || null,
|
||||
delivery_target: channel ? target : null,
|
||||
});
|
||||
setPrompt("");
|
||||
delivery_target_ref: channel ? deliveryTargetRef : null,
|
||||
};
|
||||
if (editingId) {
|
||||
await api.updateScheduledRun(editingId, payload);
|
||||
} else {
|
||||
await api.createScheduledRun(payload);
|
||||
}
|
||||
resetComposer();
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
setComposerError(error instanceof ApiError ? error.message : String(error));
|
||||
@@ -189,6 +275,7 @@ export function Scheduled() {
|
||||
setPendingDelete(null);
|
||||
try {
|
||||
await api.deleteScheduledRun(id);
|
||||
if (editingId === id) resetComposer();
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
setListError(error instanceof ApiError ? error.message : String(error));
|
||||
@@ -276,6 +363,27 @@ export function Scheduled() {
|
||||
}
|
||||
}
|
||||
|
||||
const configuredDeliveryChannels = Object.values(channelStatus)
|
||||
.filter((channel) => channel.configured && channel.enabled && channel.available)
|
||||
.sort((a, b) => a.display_name.localeCompare(b.display_name));
|
||||
|
||||
const selectedChannel = deliveryChannel ? channelStatus[deliveryChannel] : undefined;
|
||||
const selectedChannelIsAvailable = configuredDeliveryChannels.some(
|
||||
(channel) => channel.name === deliveryChannel,
|
||||
);
|
||||
const targetLabel =
|
||||
selectedChannel?.delivery_target_label ||
|
||||
(selectedChannel?.delivery_target_kind
|
||||
? t(`scheduled.deliveryTargetKind_${selectedChannel.delivery_target_kind}`, {
|
||||
defaultValue: t("scheduled.deliveryTargetLabel"),
|
||||
})
|
||||
: t("scheduled.deliveryTargetLabel"));
|
||||
const targetPlaceholder =
|
||||
selectedChannel?.delivery_target_placeholder || t("scheduled.deliveryTargetPlaceholder");
|
||||
const targetInputType =
|
||||
selectedChannel?.delivery_target_input_type === "email" ? "email" : "text";
|
||||
const targetSuggestions = selectedChannel?.delivery_target_suggestions ?? [];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6 p-6">
|
||||
<header className="flex items-center gap-3">
|
||||
@@ -286,7 +394,20 @@ export function Scheduled() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form onSubmit={handleCreate} className="space-y-4 rounded-lg border bg-card p-4">
|
||||
<form onSubmit={handleSubmit} className="space-y-4 rounded-lg border bg-card p-4">
|
||||
{editingId && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-md border bg-muted/40 px-3 py-2">
|
||||
<p className="text-sm">{t("scheduled.editingHint")}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetComposer}
|
||||
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground transition hover:text-foreground"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" aria-hidden />
|
||||
{t("scheduled.cancelEdit")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="scheduled-prompt" className={labelClass}>
|
||||
{t("scheduled.promptLabel")}
|
||||
@@ -389,26 +510,90 @@ export function Scheduled() {
|
||||
<label htmlFor="scheduled-delivery-channel" className={labelClass}>
|
||||
{t("scheduled.deliveryChannelLabel")}
|
||||
</label>
|
||||
<input
|
||||
<select
|
||||
id="scheduled-delivery-channel"
|
||||
value={deliveryChannel}
|
||||
onChange={(e) => setDeliveryChannel(e.target.value)}
|
||||
placeholder={t("scheduled.deliveryChannelPlaceholder")}
|
||||
onChange={(e) => {
|
||||
const nextChannel = e.target.value;
|
||||
if (nextChannel !== deliveryChannel) {
|
||||
setDeliveryTarget("");
|
||||
setDeliveryTargetRef(null);
|
||||
}
|
||||
setDeliveryChannel(nextChannel);
|
||||
}}
|
||||
className={fieldClass}
|
||||
/>
|
||||
<p className={hintClass}>{t("scheduled.deliveryHint")}</p>
|
||||
>
|
||||
<option value="">{t("scheduled.deliveryInApp")}</option>
|
||||
{deliveryChannel && !selectedChannelIsAvailable && (
|
||||
<option value={deliveryChannel}>
|
||||
{t("scheduled.deliveryChannelUnavailable", {
|
||||
channel: selectedChannel?.display_name || deliveryChannel,
|
||||
})}
|
||||
</option>
|
||||
)}
|
||||
{configuredDeliveryChannels.map((channel) => (
|
||||
<option key={channel.name} value={channel.name}>
|
||||
{channel.display_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{channelLoadError ? (
|
||||
<p className="text-xs text-danger">{t("scheduled.deliveryChannelsLoadFailed")}</p>
|
||||
) : configuredDeliveryChannels.length === 0 && !deliveryChannel ? (
|
||||
<p className={hintClass}>{t("scheduled.deliveryNoChannels")}</p>
|
||||
) : (
|
||||
<p className={hintClass}>{t("scheduled.deliveryHint")}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="scheduled-delivery-target" className={labelClass}>
|
||||
{t("scheduled.deliveryTargetLabel")}
|
||||
{targetLabel}
|
||||
</label>
|
||||
<input
|
||||
id="scheduled-delivery-target"
|
||||
type={targetInputType}
|
||||
disabled={!deliveryChannel}
|
||||
value={deliveryTarget}
|
||||
onChange={(e) => setDeliveryTarget(e.target.value)}
|
||||
placeholder={t("scheduled.deliveryTargetPlaceholder")}
|
||||
onChange={(e) => {
|
||||
setDeliveryTarget(e.target.value);
|
||||
setDeliveryTargetRef(null);
|
||||
}}
|
||||
placeholder={
|
||||
deliveryChannel
|
||||
? targetPlaceholder
|
||||
: t("scheduled.deliveryTargetDisabled")
|
||||
}
|
||||
className={fieldClass}
|
||||
/>
|
||||
{deliveryChannel && targetSuggestions.length > 0 && (
|
||||
<select
|
||||
aria-label={t("scheduled.deliveryKnownTargetLabel")}
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
if (!e.target.value) return;
|
||||
setDeliveryTarget(e.target.value);
|
||||
setDeliveryTargetRef(null);
|
||||
}}
|
||||
className={fieldClass}
|
||||
>
|
||||
<option value="">{t("scheduled.deliveryKnownTargetPlaceholder")}</option>
|
||||
{targetSuggestions.map((suggestion) => {
|
||||
const label = suggestion.kind
|
||||
? t(`scheduled.deliverySuggestionKind_${suggestion.kind}`, {
|
||||
defaultValue: suggestion.label || suggestion.target,
|
||||
})
|
||||
: suggestion.label || suggestion.target;
|
||||
return (
|
||||
<option
|
||||
key={`${suggestion.kind || "target"}:${suggestion.target}`}
|
||||
value={suggestion.target}
|
||||
>
|
||||
{label} · {maskDeliveryTarget(suggestion.target)}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -424,9 +609,25 @@ export function Scheduled() {
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> : <Plus className="h-4 w-4" aria-hidden />}
|
||||
{t("scheduled.create")}
|
||||
{saving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
||||
) : editingId ? (
|
||||
<Save className="h-4 w-4" aria-hidden />
|
||||
) : (
|
||||
<Plus className="h-4 w-4" aria-hidden />
|
||||
)}
|
||||
{editingId ? t("scheduled.saveChanges") : t("scheduled.create")}
|
||||
</button>
|
||||
{editingId && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={resetComposer}
|
||||
className="rounded-md border px-3 py-2 text-sm text-muted-foreground transition hover:bg-muted disabled:opacity-60"
|
||||
>
|
||||
{t("scheduled.cancelEdit")}
|
||||
</button>
|
||||
)}
|
||||
<p className={hintClass}>{t("scheduled.executorHint")}</p>
|
||||
</div>
|
||||
</form>
|
||||
@@ -470,7 +671,7 @@ export function Scheduled() {
|
||||
<span className={hintClass}>{zone}</span>
|
||||
<StatusPill label={status.label} tone={status.tone} />
|
||||
</div>
|
||||
<p className="truncate text-sm text-muted-foreground">{run.prompt}</p>
|
||||
<p className="line-clamp-2 break-words text-sm text-muted-foreground">{run.prompt}</p>
|
||||
<p className={hintClass}>
|
||||
{run.status === "expired"
|
||||
? t("scheduled.noFurtherRuns", { defaultValue: "No further runs" })
|
||||
@@ -492,58 +693,91 @@ export function Scheduled() {
|
||||
</p>
|
||||
)}
|
||||
{run.delivery_channel && (
|
||||
<p
|
||||
className={
|
||||
run.delivery_status === "failed"
|
||||
? "break-words text-xs text-danger"
|
||||
: hintClass
|
||||
}
|
||||
>
|
||||
{t(`scheduled.delivery_${run.delivery_status}`, {
|
||||
channel: run.delivery_channel,
|
||||
defaultValue: t("scheduled.delivery_none", {
|
||||
channel: run.delivery_channel,
|
||||
}),
|
||||
})}
|
||||
{run.delivery_status === "failed" && run.delivery_error
|
||||
? ` — ${run.delivery_error}`
|
||||
: ""}
|
||||
</p>
|
||||
<>
|
||||
<p className={hintClass}>
|
||||
{t("scheduled.deliverySummary", {
|
||||
channel:
|
||||
channelStatus[run.delivery_channel]?.display_name ||
|
||||
run.delivery_channel,
|
||||
target:
|
||||
run.delivery_target_label ||
|
||||
run.delivery_target ||
|
||||
t("scheduled.deliveryUnknownTarget"),
|
||||
})}
|
||||
</p>
|
||||
<p
|
||||
className={
|
||||
run.delivery_status === "failed"
|
||||
? "break-words text-xs text-danger"
|
||||
: hintClass
|
||||
}
|
||||
>
|
||||
{t(`scheduled.delivery_${run.delivery_status}`, {
|
||||
channel:
|
||||
channelStatus[run.delivery_channel]?.display_name ||
|
||||
run.delivery_channel,
|
||||
defaultValue: t("scheduled.delivery_none", {
|
||||
channel:
|
||||
channelStatus[run.delivery_channel]?.display_name ||
|
||||
run.delivery_channel,
|
||||
}),
|
||||
})}
|
||||
{run.delivery_status === "failed" && run.delivery_error
|
||||
? ` — ${run.delivery_error}`
|
||||
: ""}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{verdictCell(run)}
|
||||
</div>
|
||||
{pendingDelete === run.id ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Cancel first so it inherits the Delete button's spot —
|
||||
an accidental double-click disarms instead of destroying. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingDelete(null)}
|
||||
className="rounded-md border px-2.5 py-1.5 text-xs text-muted-foreground transition hover:bg-muted"
|
||||
>
|
||||
{t("layout.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleConfirmedDelete(run.id)}
|
||||
aria-label={t("scheduled.confirmDeleteAria", { prompt: run.prompt })}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-danger bg-danger/10 px-2.5 py-1.5 text-xs text-danger transition"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" aria-hidden />
|
||||
{t("scheduled.confirmDelete")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingDelete(run.id)}
|
||||
aria-label={t("scheduled.deleteAria", { prompt: run.prompt })}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs text-muted-foreground transition hover:bg-muted"
|
||||
onClick={() => beginEdit(run)}
|
||||
disabled={run.status === "running"}
|
||||
aria-label={t("scheduled.editAria", { prompt: run.prompt })}
|
||||
title={
|
||||
run.status === "running"
|
||||
? t("scheduled.editRunningDisabled")
|
||||
: undefined
|
||||
}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs text-muted-foreground transition hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" aria-hidden />
|
||||
{t("scheduled.delete")}
|
||||
<Pencil className="h-3.5 w-3.5" aria-hidden />
|
||||
{t("scheduled.edit")}
|
||||
</button>
|
||||
)}
|
||||
{pendingDelete === run.id ? (
|
||||
<>
|
||||
{/* Cancel first so an accidental double-click disarms instead of destroying. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingDelete(null)}
|
||||
className="rounded-md border px-2.5 py-1.5 text-xs text-muted-foreground transition hover:bg-muted"
|
||||
>
|
||||
{t("layout.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleConfirmedDelete(run.id)}
|
||||
aria-label={t("scheduled.confirmDeleteAria", { prompt: run.prompt })}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-danger bg-danger/10 px-2.5 py-1.5 text-xs text-danger transition"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" aria-hidden />
|
||||
{t("scheduled.confirmDelete")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingDelete(run.id)}
|
||||
aria-label={t("scheduled.deleteAria", { prompt: run.prompt })}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs text-muted-foreground transition hover:bg-muted"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" aria-hidden />
|
||||
{t("scheduled.delete")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -11,7 +11,9 @@ vi.mock("@/lib/api", async (importOriginal) => {
|
||||
...actual.api,
|
||||
listScheduledRuns: vi.fn(),
|
||||
createScheduledRun: vi.fn(),
|
||||
updateScheduledRun: vi.fn(),
|
||||
deleteScheduledRun: vi.fn(),
|
||||
getChannelStatus: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -19,13 +21,19 @@ vi.mock("@/lib/api", async (importOriginal) => {
|
||||
const mocked = api as unknown as {
|
||||
listScheduledRuns: ReturnType<typeof vi.fn>;
|
||||
createScheduledRun: ReturnType<typeof vi.fn>;
|
||||
updateScheduledRun: ReturnType<typeof vi.fn>;
|
||||
deleteScheduledRun: ReturnType<typeof vi.fn>;
|
||||
getChannelStatus: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function run(overrides: Partial<ScheduledRun> = {}): ScheduledRun {
|
||||
return {
|
||||
id: "auckland-scan",
|
||||
prompt: "pre-open scan of NZX names",
|
||||
title: "",
|
||||
source_type: "prompt",
|
||||
playbook_slug: null,
|
||||
end_at: null,
|
||||
schedule: "30 23 * * 1-5",
|
||||
next_run_at: 1_790_000_000_000,
|
||||
status: "pending",
|
||||
@@ -38,9 +46,13 @@ function run(overrides: Partial<ScheduledRun> = {}): ScheduledRun {
|
||||
timezone: "Pacific/Auckland",
|
||||
delivery_channel: null,
|
||||
delivery_target: null,
|
||||
delivery_target_ref: null,
|
||||
delivery_target_label: null,
|
||||
delivery_status: "none",
|
||||
delivery_error: null,
|
||||
delivery_updated_at: null,
|
||||
delivery_attempts: 0,
|
||||
delivery_provider_message_id: null,
|
||||
last_verdict: null,
|
||||
...overrides,
|
||||
};
|
||||
@@ -49,6 +61,53 @@ function run(overrides: Partial<ScheduledRun> = {}): ScheduledRun {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocked.listScheduledRuns.mockResolvedValue([]);
|
||||
mocked.getChannelStatus.mockResolvedValue({
|
||||
running: true,
|
||||
inbound_queue: 0,
|
||||
outbound_queue: 0,
|
||||
session_count: 0,
|
||||
channels: {
|
||||
email: {
|
||||
name: "email",
|
||||
display_name: "Email",
|
||||
configured: true,
|
||||
enabled: true,
|
||||
available: true,
|
||||
loaded: true,
|
||||
running: true,
|
||||
delivery_target_label: "Recipient email address",
|
||||
delivery_target_kind: "email_address",
|
||||
delivery_target_placeholder: "name@example.com",
|
||||
delivery_target_input_type: "email",
|
||||
},
|
||||
telegram: {
|
||||
name: "telegram",
|
||||
display_name: "Telegram",
|
||||
configured: true,
|
||||
enabled: true,
|
||||
available: true,
|
||||
loaded: true,
|
||||
running: true,
|
||||
delivery_target_label: "Telegram chat",
|
||||
delivery_target_kind: "chat",
|
||||
delivery_target_placeholder: "Chat, group, or user ID",
|
||||
delivery_target_input_type: "text",
|
||||
delivery_target_suggestions: [
|
||||
{ kind: "private_chat", target: "1381234567" },
|
||||
{ kind: "private_chat", target: "123456" },
|
||||
],
|
||||
},
|
||||
discord: {
|
||||
name: "discord",
|
||||
display_name: "Discord",
|
||||
configured: true,
|
||||
enabled: false,
|
||||
available: true,
|
||||
loaded: false,
|
||||
running: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("Scheduled page", () => {
|
||||
@@ -92,6 +151,91 @@ describe("Scheduled page", () => {
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
it("edits prompt, wall-clock schedule, and delivery without recreating the job", async () => {
|
||||
mocked.listScheduledRuns.mockResolvedValue([
|
||||
run({
|
||||
prompt: "old briefing",
|
||||
schedule: "20 10 * * 1-5",
|
||||
timezone: "America/Buenos_Aires",
|
||||
delivery_channel: "email",
|
||||
delivery_target: "old@example.com",
|
||||
}),
|
||||
]);
|
||||
mocked.updateScheduledRun.mockResolvedValue(run());
|
||||
render(<Scheduled />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: /Edit scheduled run/ }));
|
||||
expect(screen.getByLabelText("Research prompt")).toHaveValue("old briefing");
|
||||
expect(screen.getByLabelText("Local time")).toHaveValue("10:20");
|
||||
expect(screen.getByLabelText("Delivery channel")).toHaveValue("email");
|
||||
expect(await screen.findByLabelText("Recipient email address")).toHaveValue("old@example.com");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Research prompt"), {
|
||||
target: { value: "updated briefing" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Local time"), { target: { value: "10:35" } });
|
||||
fireEvent.change(screen.getByLabelText("Recipient email address"), {
|
||||
target: { value: "new@example.com" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocked.updateScheduledRun).toHaveBeenCalledWith(
|
||||
"auckland-scan",
|
||||
expect.objectContaining({
|
||||
prompt: "updated briefing",
|
||||
schedule: "35 10 * * 1-5",
|
||||
timezone: "America/Buenos_Aires",
|
||||
delivery_channel: "email",
|
||||
delivery_target: "new@example.com",
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(mocked.createScheduledRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lists only enabled configured channels and adapts the destination field", async () => {
|
||||
render(<Scheduled />);
|
||||
await screen.findByText(/No scheduled runs yet/);
|
||||
|
||||
const channel = screen.getByLabelText("Delivery channel");
|
||||
await within(channel).findByRole("option", { name: "Email" });
|
||||
expect(within(channel).getByRole("option", { name: "Email" })).toBeInTheDocument();
|
||||
expect(within(channel).getByRole("option", { name: "Telegram" })).toBeInTheDocument();
|
||||
expect(within(channel).queryByRole("option", { name: "Discord" })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(channel, { target: { value: "email" } });
|
||||
const target = screen.getByLabelText("Recipient email address");
|
||||
expect(target).toHaveAttribute("type", "email");
|
||||
expect(target).toHaveAttribute("placeholder", "name@example.com");
|
||||
});
|
||||
|
||||
it("keeps manual target entry and can fill it from a known Telegram destination", async () => {
|
||||
render(<Scheduled />);
|
||||
await screen.findByText(/No scheduled runs yet/);
|
||||
|
||||
const channel = screen.getByLabelText("Delivery channel");
|
||||
await within(channel).findByRole("option", { name: "Telegram" });
|
||||
fireEvent.change(channel, { target: { value: "telegram" } });
|
||||
|
||||
const target = await screen.findByLabelText("Telegram chat");
|
||||
expect(target).toHaveValue("");
|
||||
expect(target).toHaveAttribute("placeholder", "Chat, group, or user ID");
|
||||
|
||||
const knownTarget = screen.getByLabelText("Known destination");
|
||||
fireEvent.change(knownTarget, { target: { value: "1381234567" } });
|
||||
|
||||
expect(target).toHaveValue("1381234567");
|
||||
expect(
|
||||
within(knownTarget).getByRole("option", { name: "Private chat · 138••••67" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(knownTarget).getByRole("option", { name: "Private chat · 1••••6" }),
|
||||
).toBeInTheDocument();
|
||||
expect(within(knownTarget).queryByRole("option", { name: /123456/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("surfaces a validation error from the backend", async () => {
|
||||
mocked.createScheduledRun.mockRejectedValue(
|
||||
new ApiError("timezone 'Not/AZone' is not a recognized IANA timezone key", 422),
|
||||
@@ -146,7 +290,7 @@ describe("Scheduled page", () => {
|
||||
|
||||
|
||||
describe("briefing delivery", () => {
|
||||
it("keeps delivery off unless a channel is typed", async () => {
|
||||
it("keeps delivery off unless a channel is selected", async () => {
|
||||
render(<Scheduled />);
|
||||
fireEvent.change(screen.getByLabelText(/prompt/i), {
|
||||
target: { value: "pre-open scan" },
|
||||
@@ -164,7 +308,9 @@ describe("briefing delivery", () => {
|
||||
fireEvent.change(screen.getByLabelText(/prompt/i), {
|
||||
target: { value: "pre-open scan" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/deliver to channel/i), {
|
||||
const channel = screen.getByLabelText(/delivery channel/i);
|
||||
await within(channel).findByRole("option", { name: "Telegram" });
|
||||
fireEvent.change(channel, {
|
||||
target: { value: "telegram" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /schedule|create/i }));
|
||||
@@ -178,10 +324,12 @@ describe("briefing delivery", () => {
|
||||
fireEvent.change(screen.getByLabelText(/prompt/i), {
|
||||
target: { value: "pre-open scan" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/deliver to channel/i), {
|
||||
target: { value: " telegram " },
|
||||
const channel = screen.getByLabelText(/delivery channel/i);
|
||||
await within(channel).findByRole("option", { name: "Telegram" });
|
||||
fireEvent.change(channel, {
|
||||
target: { value: "telegram" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/channel target/i), {
|
||||
fireEvent.change(await screen.findByLabelText(/telegram chat/i), {
|
||||
target: { value: " chat-9 " },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /schedule|create/i }));
|
||||
|
||||
Reference in new issue
Block a user