feat(tui): edit automation schedules and pin model routes

Add a draft editor to /automation for names, multiline prompts, schedule
presets, clock times, weekdays, models, workspaces and enabled status.
Save and Cancel have keyboard and mouse controls; next-run previews use
the canonical local-time scheduler. Preserve existing permissions and
unsupported schedules when editing other fields.

Carry exact optional provider pins through automation and task records to
the existing runtime thread resolver. Legacy records still inherit their
provider. New schemas v2/v3 prevent older runtimes from ignoring pins.
Reject pinned HTTP tasks without an explicit model. Translate all new UI
strings in the 15 complete locale packs.

Validation:
- Focused scheduler/task/editor/API/tool/localization regressions:
  176 passed, 0 failed, 0 ignored.
- Real PTY create/edit/cancel/save/persist/process-restart acceptance:
  1 passed, 0 failed, across 40x12, 60x16, 80x24, 100x32 and 140x40.
- Provider and HTTP regression baseline with fixes removed:
  0 passed, 2 failed as expected; restored fixes pass.
- Locale parity: 15/15 packs, 2077 keys each; 510 editor placeholder checks.
- TUI/CLI checks, cargo fmt --check and git diff --check passed.
- Required npm gate: npm test and npm run check:web both failed with
  Missing script (2 failed commands, 0 npm tests executed).

No provider inference/spend, public push, deployment or publication.
This commit is contained in:
CodeWhale Bot committed 2026-09-04 15:57:28 -07:00
1 parent 8da0d9e4dc
commit 1228c4f017
33 files changed
+2879 -87

No files matched your search

+36 -2
View File
@@ -1664,7 +1664,7 @@
"AutomationManagerUnavailable": "El gestor d’automatitzacions no està disponible en aquesta sessió.",
"AutomationListFailed": "No s’han pogut llistar les automatitzacions: {error}",
"AutomationActionFailed": "No s’ha pogut {action} l’automatització {id}: {error}",
"AutomationEmpty": "No hi ha automatitzacions programades. Utilitza l’eina `automation` per crear-ne una.",
"AutomationEmpty": "No hi ha automatitzacions programades. Tria Automatització nova.",
"AutomationListHeading": "Automatitzacions programades",
"AutomationScopeNote": "Les automatitzacions són teves, no d'aquest repositori: et segueixen a tots els repositoris.",
"AutomationNoun": "Automatització",
@@ -2041,5 +2041,39 @@
"LaunchSeeAllSessions": "Mostra totes les sessions…",
"LaunchNoRecentSessions": "Encara no hi ha sessions recents — escriu a sota per començar.",
"LaunchResumeFailed": "La represa ha fallat: {error}",
"ReceiptSessionHooks": "hooks {count}"
"ReceiptSessionHooks": "hooks {count}",
"AutomationEditorNew": "Automatització nova",
"AutomationEditorEdit": "Edita l’automatització",
"AutomationEditorSchedule": "Programació",
"AutomationEditorDaily": "Diària",
"AutomationEditorWeekly": "Setmanal",
"AutomationEditorHourly": "Cada hora",
"AutomationEditorOnce": "Una vegada",
"AutomationEditorCustom": "Personalitzada",
"AutomationEditorTime": "Hora (HH:MM)",
"AutomationEditorDate": "Data (YYYY-MM-DD)",
"AutomationEditorDays": "Dies",
"AutomationEditorLocalTime": "Fus horari: local ({zone})",
"AutomationEditorProvider": "El model fixa el proveïdor. Configuració actual: {provider}.",
"AutomationEditorDefaultModel": "Model per defecte",
"AutomationEditorInheritedProvider": "valors per defecte del sistema",
"AutomationEditorEnabled": "Activada",
"AutomationEditorControls": "Tab camps · Enter edita · ↑↓ ajusta · Ctrl+A selecciona-ho tot",
"AutomationEditorPromptControls": "Enter salt de línia · Tab camp següent · Ctrl+A selecciona-ho tot",
"AutomationEditorSaveFailed": "No s’ha pogut desar: {error}",
"AutomationEditorBusy": "El magatzem està ocupat. Torna a desar.",
"AutomationEditorSaved": "S’ha desat {name}",
"AutomationEditorPreviewUnavailable": "No es pot calcular l’execució següent; es mantindrà la programació",
"AutomationEditorConflict": "L’automatització ha canviat. Cancel·la i torna-la a obrir.",
"AutomationEditorInvalidWorkspace": "Tria un directori de treball existent.",
"AutomationEditorModelControls": "Escriu per filtrar · ↑↓ mou · Enter tria · Esc enrere",
"AutomationEditorPausedPreview": "En pausa · cap execució programada",
"AutomationEditorInvalidSchedule": "Programació no vàlida: {error}",
"AutomationEditorMonday": "Dl",
"AutomationEditorTuesday": "Dt",
"AutomationEditorWednesday": "Dc",
"AutomationEditorThursday": "Dj",
"AutomationEditorFriday": "Dv",
"AutomationEditorSaturday": "Ds",
"AutomationEditorSunday": "Dg"
}
+36 -2
View File
@@ -1664,7 +1664,7 @@
"AutomationManagerUnavailable": "Der Automatisierungsmanager ist in dieser Sitzung nicht verfügbar.",
"AutomationListFailed": "Automatisierungen konnten nicht aufgelistet werden: {error}",
"AutomationActionFailed": "Automatisierung {id} konnte nicht {action} werden: {error}",
"AutomationEmpty": "Keine geplanten Automatisierungen. Mit dem Werkzeug `automation` kann eine erstellt werden.",
"AutomationEmpty": "Keine geplanten Automationen. Neue Automation auswählen.",
"AutomationListHeading": "Geplante Automatisierungen",
"AutomationScopeNote": "Automationen gehören dir, nicht diesem Repository – sie folgen dir in jedes Repository.",
"AutomationNoun": "Automatisierung",
@@ -2041,5 +2041,39 @@
"LaunchSeeAllSessions": "Alle Sitzungen anzeigen…",
"LaunchNoRecentSessions": "Noch keine aktuellen Sitzungen — unten tippen, um zu starten.",
"LaunchResumeFailed": "Fortsetzen fehlgeschlagen: {error}",
"ReceiptSessionHooks": "Hooks {count}"
"ReceiptSessionHooks": "Hooks {count}",
"AutomationEditorNew": "Neue Automation",
"AutomationEditorEdit": "Automation bearbeiten",
"AutomationEditorSchedule": "Zeitplan",
"AutomationEditorDaily": "Täglich",
"AutomationEditorWeekly": "Wöchentlich",
"AutomationEditorHourly": "Stündlich",
"AutomationEditorOnce": "Einmal",
"AutomationEditorCustom": "Benutzerdefiniert",
"AutomationEditorTime": "Uhrzeit (HH:MM)",
"AutomationEditorDate": "Datum (YYYY-MM-DD)",
"AutomationEditorDays": "Tage",
"AutomationEditorLocalTime": "Zeitzone: lokal ({zone})",
"AutomationEditorProvider": "Die Modellwahl bindet den Anbieter. Aktuelle Konfiguration: {provider}.",
"AutomationEditorDefaultModel": "Standardmodell",
"AutomationEditorInheritedProvider": "Laufzeitvorgaben",
"AutomationEditorEnabled": "Aktiviert",
"AutomationEditorControls": "Tab Felder · Enter bearbeiten · ↑↓ anpassen · Ctrl+A alles auswählen",
"AutomationEditorPromptControls": "Enter neue Zeile · Tab nächstes Feld · Ctrl+A alles auswählen",
"AutomationEditorSaveFailed": "Speichern fehlgeschlagen: {error}",
"AutomationEditorBusy": "Automationsspeicher ist belegt. Erneut speichern.",
"AutomationEditorSaved": "{name} gespeichert",
"AutomationEditorPreviewUnavailable": "Nächster Lauf unbekannt; vorhandener Zeitplan bleibt erhalten",
"AutomationEditorConflict": "Automation wurde geändert. Abbrechen und erneut öffnen.",
"AutomationEditorInvalidWorkspace": "Ein vorhandenes Arbeitsverzeichnis auswählen.",
"AutomationEditorModelControls": "Tippen zum Filtern · ↑↓ bewegen · Enter wählen · Esc zurück",
"AutomationEditorPausedPreview": "Pausiert · kein geplanter Lauf",
"AutomationEditorInvalidSchedule": "Ungültiger Zeitplan: {error}",
"AutomationEditorMonday": "Mo",
"AutomationEditorTuesday": "Di",
"AutomationEditorWednesday": "Mi",
"AutomationEditorThursday": "Do",
"AutomationEditorFriday": "Fr",
"AutomationEditorSaturday": "Sa",
"AutomationEditorSunday": "So"
}
+36 -2
View File
@@ -1664,7 +1664,7 @@
"AutomationManagerUnavailable": "Automation manager is not available in this session.",
"AutomationListFailed": "Could not list automations: {error}",
"AutomationActionFailed": "Could not {action} automation {id}: {error}",
"AutomationEmpty": "No scheduled automations. Use the `automation` tool to create one.",
"AutomationEmpty": "No scheduled automations. Choose New automation.",
"AutomationListHeading": "Scheduled automations",
"AutomationScopeNote": "Automations are yours, not this repository's — they follow you into every repository.",
"AutomationNoun": "Automation",
@@ -2041,5 +2041,39 @@
"LaunchSeeAllSessions": "See all sessions…",
"LaunchNoRecentSessions": "No recent sessions yet — type below to start.",
"LaunchResumeFailed": "Resume failed: {error}",
"ReceiptSessionHooks": "hooks {count}"
"ReceiptSessionHooks": "hooks {count}",
"AutomationEditorNew": "New automation",
"AutomationEditorEdit": "Edit automation",
"AutomationEditorSchedule": "Schedule",
"AutomationEditorDaily": "Daily",
"AutomationEditorWeekly": "Weekly",
"AutomationEditorHourly": "Hourly",
"AutomationEditorOnce": "Once",
"AutomationEditorCustom": "Custom",
"AutomationEditorTime": "Time (HH:MM)",
"AutomationEditorDate": "Date (YYYY-MM-DD)",
"AutomationEditorDays": "Days",
"AutomationEditorLocalTime": "Time zone: local ({zone})",
"AutomationEditorProvider": "Model choices pin their provider. Current configuration: {provider}.",
"AutomationEditorDefaultModel": "Default model",
"AutomationEditorInheritedProvider": "runtime defaults",
"AutomationEditorEnabled": "Enabled",
"AutomationEditorControls": "Tab fields · Enter edit · ↑↓ adjust · Ctrl+A select all",
"AutomationEditorPromptControls": "Enter newline · Tab next field · Ctrl+A select all",
"AutomationEditorSaveFailed": "Could not save: {error}",
"AutomationEditorBusy": "Automation store is busy. Retry Save.",
"AutomationEditorSaved": "Saved {name}",
"AutomationEditorPreviewUnavailable": "Next run unavailable; existing schedule will be kept",
"AutomationEditorConflict": "Automation changed since opening. Cancel and reopen.",
"AutomationEditorInvalidWorkspace": "Choose an existing workspace directory.",
"AutomationEditorModelControls": "Type to filter · ↑↓ move · Enter select · Esc back",
"AutomationEditorPausedPreview": "Paused · no scheduled run",
"AutomationEditorInvalidSchedule": "Invalid schedule: {error}",
"AutomationEditorMonday": "Mon",
"AutomationEditorTuesday": "Tue",
"AutomationEditorWednesday": "Wed",
"AutomationEditorThursday": "Thu",
"AutomationEditorFriday": "Fri",
"AutomationEditorSaturday": "Sat",
"AutomationEditorSunday": "Sun"
}
+36 -2
View File
@@ -1664,7 +1664,7 @@
"AutomationManagerUnavailable": "El administrador de automatizaciones no está disponible en esta sesión.",
"AutomationListFailed": "No se pudieron listar las automatizaciones: {error}",
"AutomationActionFailed": "No se pudo {action} la automatización {id}: {error}",
"AutomationEmpty": "No hay automatizaciones programadas. Usa la herramienta `automation` para crear una.",
"AutomationEmpty": "No hay automatizaciones programadas. Elige Nueva automatización.",
"AutomationListHeading": "Automatizaciones programadas",
"AutomationScopeNote": "Las automatizaciones son tuyas, no de este repositorio: te siguen a todos los repositorios.",
"AutomationNoun": "Automatización",
@@ -2041,5 +2041,39 @@
"LaunchSeeAllSessions": "Ver todas las sesiones…",
"LaunchNoRecentSessions": "Aún no hay sesiones recientes — escribe abajo para empezar.",
"LaunchResumeFailed": "No se pudo reanudar: {error}",
"ReceiptSessionHooks": "hooks {count}"
"ReceiptSessionHooks": "hooks {count}",
"AutomationEditorNew": "Nueva automatización",
"AutomationEditorEdit": "Editar automatización",
"AutomationEditorSchedule": "Programación",
"AutomationEditorDaily": "Diaria",
"AutomationEditorWeekly": "Semanal",
"AutomationEditorHourly": "Cada hora",
"AutomationEditorOnce": "Una vez",
"AutomationEditorCustom": "Personalizada",
"AutomationEditorTime": "Hora (HH:MM)",
"AutomationEditorDate": "Fecha (YYYY-MM-DD)",
"AutomationEditorDays": "Días",
"AutomationEditorLocalTime": "Zona horaria: local ({zone})",
"AutomationEditorProvider": "El modelo fija su proveedor. Configuración actual: {provider}.",
"AutomationEditorDefaultModel": "Modelo predeterminado",
"AutomationEditorInheritedProvider": "valores predeterminados del sistema",
"AutomationEditorEnabled": "Habilitada",
"AutomationEditorControls": "Tab campos · Enter editar · ↑↓ ajustar · Ctrl+A seleccionar todo",
"AutomationEditorPromptControls": "Enter nueva línea · Tab siguiente campo · Ctrl+A seleccionar todo",
"AutomationEditorSaveFailed": "No se pudo guardar: {error}",
"AutomationEditorBusy": "El almacén está ocupado. Vuelve a guardar.",
"AutomationEditorSaved": "Se guardó {name}",
"AutomationEditorPreviewUnavailable": "Próxima ejecución no disponible; se conservará la programación",
"AutomationEditorConflict": "La automatización cambió. Cancela y vuelve a abrirla.",
"AutomationEditorInvalidWorkspace": "Elige un directorio de trabajo existente.",
"AutomationEditorModelControls": "Escribe para filtrar · ↑↓ mover · Enter elegir · Esc volver",
"AutomationEditorPausedPreview": "En pausa · sin ejecución programada",
"AutomationEditorInvalidSchedule": "Programación no válida: {error}",
"AutomationEditorMonday": "Lun",
"AutomationEditorTuesday": "Mar",
"AutomationEditorWednesday": "Mié",
"AutomationEditorThursday": "Jue",
"AutomationEditorFriday": "Vie",
"AutomationEditorSaturday": "Sáb",
"AutomationEditorSunday": "Dom"
}
+36 -2
View File
@@ -1664,7 +1664,7 @@
"AutomationManagerUnavailable": "Le gestionnaire d’automatisations n’est pas disponible dans cette session.",
"AutomationListFailed": "Impossible de lister les automatisations : {error}",
"AutomationActionFailed": "Impossible de {action} l’automatisation {id} : {error}",
"AutomationEmpty": "Aucune automatisation planifiée. Utilisez l’outil `automation` pour en créer une.",
"AutomationEmpty": "Aucune automatisation planifiée. Choisissez Nouvelle automatisation.",
"AutomationListHeading": "Automatisations planifiées",
"AutomationScopeNote": "Les automatisations sont à vous, pas à ce dépôt : elles vous suivent dans chaque dépôt.",
"AutomationNoun": "Automatisation",
@@ -2041,5 +2041,39 @@
"LaunchSeeAllSessions": "Voir toutes les sessions…",
"LaunchNoRecentSessions": "Aucune session récente — écrivez ci-dessous pour commencer.",
"LaunchResumeFailed": "Échec de la reprise : {error}",
"ReceiptSessionHooks": "hooks {count}"
"ReceiptSessionHooks": "hooks {count}",
"AutomationEditorNew": "Nouvelle automatisation",
"AutomationEditorEdit": "Modifier l’automatisation",
"AutomationEditorSchedule": "Planification",
"AutomationEditorDaily": "Quotidienne",
"AutomationEditorWeekly": "Hebdomadaire",
"AutomationEditorHourly": "Toutes les heures",
"AutomationEditorOnce": "Une fois",
"AutomationEditorCustom": "Personnalisée",
"AutomationEditorTime": "Heure (HH:MM)",
"AutomationEditorDate": "Date (YYYY-MM-DD)",
"AutomationEditorDays": "Jours",
"AutomationEditorLocalTime": "Fuseau horaire : local ({zone})",
"AutomationEditorProvider": "Le modèle fixe son fournisseur. Configuration actuelle : {provider}.",
"AutomationEditorDefaultModel": "Modèle par défaut",
"AutomationEditorInheritedProvider": "réglages par défaut du système",
"AutomationEditorEnabled": "Activée",
"AutomationEditorControls": "Tab champs · Enter modifier · ↑↓ ajuster · Ctrl+A tout sélectionner",
"AutomationEditorPromptControls": "Enter nouvelle ligne · Tab champ suivant · Ctrl+A tout sélectionner",
"AutomationEditorSaveFailed": "Impossible d’enregistrer : {error}",
"AutomationEditorBusy": "Le stockage est occupé. Réessayez d’enregistrer.",
"AutomationEditorSaved": "{name} enregistré",
"AutomationEditorPreviewUnavailable": "Prochaine exécution indisponible ; la planification sera conservée",
"AutomationEditorConflict": "L’automatisation a changé. Annulez puis rouvrez-la.",
"AutomationEditorInvalidWorkspace": "Choisissez un dossier de travail existant.",
"AutomationEditorModelControls": "Saisir pour filtrer · ↑↓ déplacer · Enter choisir · Esc retour",
"AutomationEditorPausedPreview": "En pause · aucune exécution prévue",
"AutomationEditorInvalidSchedule": "Planification invalide : {error}",
"AutomationEditorMonday": "Lun",
"AutomationEditorTuesday": "Mar",
"AutomationEditorWednesday": "Mer",
"AutomationEditorThursday": "Jeu",
"AutomationEditorFriday": "Ven",
"AutomationEditorSaturday": "Sam",
"AutomationEditorSunday": "Dim"
}
+36 -2
View File
@@ -1664,7 +1664,7 @@
"AutomationManagerUnavailable": "इस सत्र में स्वचालन प्रबंधक उपलब्ध नहीं है।",
"AutomationListFailed": "स्वचालनों की सूची नहीं मिल सकी: {error}",
"AutomationActionFailed": "स्वचालन {id} को {action} नहीं किया जा सका: {error}",
"AutomationEmpty": "कोई अनुसूचित स्वचालन नहीं है। नया बनाने के लिए `automation` उपकरण का उपयोग करें।",
"AutomationEmpty": "कोई स्वचालन निर्धारित नहीं है। नया स्वचालन चुनें।",
"AutomationListHeading": "अनुसूचित स्वचालन",
"AutomationScopeNote": "ऑटोमेशन आपके हैं, इस रिपॉज़िटरी के नहीं — वे हर रिपॉज़िटरी में आपके साथ चलते हैं।",
"AutomationNoun": "स्वचालन",
@@ -2041,5 +2041,39 @@
"LaunchSeeAllSessions": "सभी सत्र देखें…",
"LaunchNoRecentSessions": "अभी कोई हालिया सत्र नहीं — शुरू करने के लिए नीचे लिखें।",
"LaunchResumeFailed": "फिर से शुरू विफल: {error}",
"ReceiptSessionHooks": "हुक {count}"
"ReceiptSessionHooks": "हुक {count}",
"AutomationEditorNew": "नया स्वचालन",
"AutomationEditorEdit": "स्वचालन संपादित करें",
"AutomationEditorSchedule": "समय-सारणी",
"AutomationEditorDaily": "रोज़ाना",
"AutomationEditorWeekly": "साप्ताहिक",
"AutomationEditorHourly": "हर घंटे",
"AutomationEditorOnce": "एक बार",
"AutomationEditorCustom": "कस्टम",
"AutomationEditorTime": "समय (HH:MM)",
"AutomationEditorDate": "तारीख (YYYY-MM-DD)",
"AutomationEditorDays": "दिन",
"AutomationEditorLocalTime": "समय क्षेत्र: स्थानीय ({zone})",
"AutomationEditorProvider": "मॉडल का प्रदाता तय रहेगा। मौजूदा कॉन्फ़िगरेशन: {provider}।",
"AutomationEditorDefaultModel": "डिफ़ॉल्ट मॉडल",
"AutomationEditorInheritedProvider": "रनटाइम के डिफ़ॉल्ट",
"AutomationEditorEnabled": "सक्षम",
"AutomationEditorControls": "Tab फ़ील्ड · Enter संपादन · ↑↓ समायोजन · Ctrl+A सब चुनें",
"AutomationEditorPromptControls": "Enter नई पंक्ति · Tab अगला फ़ील्ड · Ctrl+A सब चुनें",
"AutomationEditorSaveFailed": "सहेजा नहीं जा सका: {error}",
"AutomationEditorBusy": "स्वचालन संग्रह व्यस्त है। फिर सहेजें।",
"AutomationEditorSaved": "{name} सहेजा गया",
"AutomationEditorPreviewUnavailable": "अगला रन उपलब्ध नहीं; मौजूदा समय-सारणी बनी रहेगी",
"AutomationEditorConflict": "स्वचालन बदल गया है। रद्द करके फिर खोलें।",
"AutomationEditorInvalidWorkspace": "मौजूदा कार्यस्थान फ़ोल्डर चुनें।",
"AutomationEditorModelControls": "फ़िल्टर के लिए लिखें · ↑↓ जाएँ · Enter चुनें · Esc वापस",
"AutomationEditorPausedPreview": "रुका हुआ · कोई रन निर्धारित नहीं",
"AutomationEditorInvalidSchedule": "अमान्य समय-सारणी: {error}",
"AutomationEditorMonday": "सोम",
"AutomationEditorTuesday": "मंगल",
"AutomationEditorWednesday": "बुध",
"AutomationEditorThursday": "गुरु",
"AutomationEditorFriday": "शुक्र",
"AutomationEditorSaturday": "शनि",
"AutomationEditorSunday": "रवि"
}
+36 -2
View File
@@ -1664,7 +1664,7 @@
"AutomationManagerUnavailable": "Pengelola otomatisasi tidak tersedia dalam sesi ini.",
"AutomationListFailed": "Tidak dapat menampilkan daftar otomatisasi: {error}",
"AutomationActionFailed": "Tidak dapat {action} otomatisasi {id}: {error}",
"AutomationEmpty": "Tidak ada otomatisasi terjadwal. Gunakan alat `automation` untuk membuatnya.",
"AutomationEmpty": "Tidak ada otomasi terjadwal. Pilih Otomasi baru.",
"AutomationListHeading": "Otomatisasi terjadwal",
"AutomationScopeNote": "Otomatisasi milik Anda, bukan repositori ini — mengikuti Anda ke setiap repositori.",
"AutomationNoun": "Otomatisasi",
@@ -2041,5 +2041,39 @@
"LaunchSeeAllSessions": "Lihat semua sesi…",
"LaunchNoRecentSessions": "Belum ada sesi terkini — ketik di bawah untuk memulai.",
"LaunchResumeFailed": "Melanjutkan gagal: {error}",
"ReceiptSessionHooks": "hook {count}"
"ReceiptSessionHooks": "hook {count}",
"AutomationEditorNew": "Otomasi baru",
"AutomationEditorEdit": "Edit otomasi",
"AutomationEditorSchedule": "Jadwal",
"AutomationEditorDaily": "Harian",
"AutomationEditorWeekly": "Mingguan",
"AutomationEditorHourly": "Setiap jam",
"AutomationEditorOnce": "Sekali",
"AutomationEditorCustom": "Kustom",
"AutomationEditorTime": "Waktu (HH:MM)",
"AutomationEditorDate": "Tanggal (YYYY-MM-DD)",
"AutomationEditorDays": "Hari",
"AutomationEditorLocalTime": "Zona waktu: lokal ({zone})",
"AutomationEditorProvider": "Pilihan model mengunci penyedianya. Konfigurasi saat ini: {provider}.",
"AutomationEditorDefaultModel": "Model bawaan",
"AutomationEditorInheritedProvider": "bawaan runtime",
"AutomationEditorEnabled": "Diaktifkan",
"AutomationEditorControls": "Tab kolom · Enter edit · ↑↓ sesuaikan · Ctrl+A pilih semua",
"AutomationEditorPromptControls": "Enter baris baru · Tab kolom berikutnya · Ctrl+A pilih semua",
"AutomationEditorSaveFailed": "Tidak dapat menyimpan: {error}",
"AutomationEditorBusy": "Penyimpanan otomasi sibuk. Coba simpan lagi.",
"AutomationEditorSaved": "{name} disimpan",
"AutomationEditorPreviewUnavailable": "Eksekusi berikutnya tidak tersedia; jadwal lama dipertahankan",
"AutomationEditorConflict": "Otomasi telah berubah. Batalkan dan buka kembali.",
"AutomationEditorInvalidWorkspace": "Pilih direktori ruang kerja yang ada.",
"AutomationEditorModelControls": "Ketik untuk memfilter · ↑↓ pindah · Enter pilih · Esc kembali",
"AutomationEditorPausedPreview": "Dijeda · tidak ada eksekusi terjadwal",
"AutomationEditorInvalidSchedule": "Jadwal tidak valid: {error}",
"AutomationEditorMonday": "Sen",
"AutomationEditorTuesday": "Sel",
"AutomationEditorWednesday": "Rab",
"AutomationEditorThursday": "Kam",
"AutomationEditorFriday": "Jum",
"AutomationEditorSaturday": "Sab",
"AutomationEditorSunday": "Min"
}
+36 -2
View File
@@ -1664,7 +1664,7 @@
"AutomationManagerUnavailable": "このセッションでは自動化マネージャーを利用できません。",
"AutomationListFailed": "自動化の一覧を取得できませんでした: {error}",
"AutomationActionFailed": "自動化 {id} を{action}できませんでした: {error}",
"AutomationEmpty": "スケジュール済みの自動化はありません。`automation` ツールで作成できます。",
"AutomationEmpty": "実行予定の自動化はありません。「自動化を作成」を選んでください。",
"AutomationListHeading": "スケジュール済みの自動化",
"AutomationScopeNote": "オートメーションはこのリポジトリではなくあなたのものです。すべてのリポジトリに付いてきます。",
"AutomationNoun": "自動化",
@@ -2041,5 +2041,39 @@
"LaunchSeeAllSessions": "すべてのセッションを表示…",
"LaunchNoRecentSessions": "最近のセッションはまだありません — 下に入力して開始します。",
"LaunchResumeFailed": "再開に失敗しました:{error}",
"ReceiptSessionHooks": "フック {count}"
"ReceiptSessionHooks": "フック {count}",
"AutomationEditorNew": "自動化を作成",
"AutomationEditorEdit": "自動化を編集",
"AutomationEditorSchedule": "スケジュール",
"AutomationEditorDaily": "毎日",
"AutomationEditorWeekly": "毎週",
"AutomationEditorHourly": "毎時",
"AutomationEditorOnce": "1回のみ",
"AutomationEditorCustom": "カスタム",
"AutomationEditorTime": "時刻 (HH:MM)",
"AutomationEditorDate": "日付 (YYYY-MM-DD)",
"AutomationEditorDays": "曜日",
"AutomationEditorLocalTime": "タイムゾーン:ローカル ({zone})",
"AutomationEditorProvider": "モデル選択でプロバイダーを固定します。現在の設定:{provider}。",
"AutomationEditorDefaultModel": "既定のモデル",
"AutomationEditorInheritedProvider": "ランタイムの既定値",
"AutomationEditorEnabled": "有効",
"AutomationEditorControls": "Tab 項目移動 · Enter 編集 · ↑↓ 調整 · Ctrl+A 全選択",
"AutomationEditorPromptControls": "Enter 改行 · Tab 次の項目 · Ctrl+A 全選択",
"AutomationEditorSaveFailed": "保存できませんでした:{error}",
"AutomationEditorBusy": "自動化の保存先が使用中です。再度保存してください。",
"AutomationEditorSaved": "{name} を保存しました",
"AutomationEditorPreviewUnavailable": "次回実行は不明です。既存のスケジュールを保持します",
"AutomationEditorConflict": "自動化が変更されました。キャンセルして開き直してください。",
"AutomationEditorInvalidWorkspace": "既存の作業ディレクトリを選んでください。",
"AutomationEditorModelControls": "入力して絞り込み · ↑↓ 移動 · Enter 選択 · Esc 戻る",
"AutomationEditorPausedPreview": "一時停止中 · 実行予定なし",
"AutomationEditorInvalidSchedule": "無効なスケジュール:{error}",
"AutomationEditorMonday": "月",
"AutomationEditorTuesday": "火",
"AutomationEditorWednesday": "水",
"AutomationEditorThursday": "木",
"AutomationEditorFriday": "金",
"AutomationEditorSaturday": "土",
"AutomationEditorSunday": "日"
}
+36 -2
View File
@@ -1664,7 +1664,7 @@
"AutomationManagerUnavailable": "이 세션에서는 자동화 관리자를 사용할 수 없습니다.",
"AutomationListFailed": "자동화 목록을 가져올 수 없습니다: {error}",
"AutomationActionFailed": "자동화 {id}을(를) {action}할 수 없습니다: {error}",
"AutomationEmpty": "예약된 자동화가 없습니다. `automation` 도구로 만들 수 있습니다.",
"AutomationEmpty": "예약된 자동화가 없습니다. 자동화 만들기를 선택하세요.",
"AutomationListHeading": "예약된 자동화",
"AutomationScopeNote": "자동화는 이 저장소가 아니라 사용자의 것입니다. 모든 저장소에서 함께 따라옵니다.",
"AutomationNoun": "자동화",
@@ -2041,5 +2041,39 @@
"LaunchSeeAllSessions": "모든 세션 보기…",
"LaunchNoRecentSessions": "최근 세션이 아직 없습니다 — 아래에 입력하여 시작하세요.",
"LaunchResumeFailed": "다시 시작 실패: {error}",
"ReceiptSessionHooks": "훅 {count}"
"ReceiptSessionHooks": "훅 {count}",
"AutomationEditorNew": "자동화 만들기",
"AutomationEditorEdit": "자동화 편집",
"AutomationEditorSchedule": "일정",
"AutomationEditorDaily": "매일",
"AutomationEditorWeekly": "매주",
"AutomationEditorHourly": "매시간",
"AutomationEditorOnce": "한 번",
"AutomationEditorCustom": "사용자 지정",
"AutomationEditorTime": "시간 (HH:MM)",
"AutomationEditorDate": "날짜 (YYYY-MM-DD)",
"AutomationEditorDays": "요일",
"AutomationEditorLocalTime": "시간대: 로컬 ({zone})",
"AutomationEditorProvider": "모델을 선택하면 제공자가 고정됩니다. 현재 설정: {provider}.",
"AutomationEditorDefaultModel": "기본 모델",
"AutomationEditorInheritedProvider": "런타임 기본값",
"AutomationEditorEnabled": "사용",
"AutomationEditorControls": "Tab 필드 이동 · Enter 편집 · ↑↓ 조정 · Ctrl+A 전체 선택",
"AutomationEditorPromptControls": "Enter 줄바꿈 · Tab 다음 필드 · Ctrl+A 전체 선택",
"AutomationEditorSaveFailed": "저장할 수 없습니다: {error}",
"AutomationEditorBusy": "자동화 저장소가 사용 중입니다. 다시 저장하세요.",
"AutomationEditorSaved": "{name} 저장됨",
"AutomationEditorPreviewUnavailable": "다음 실행을 알 수 없어 기존 일정을 유지합니다",
"AutomationEditorConflict": "자동화가 변경되었습니다. 취소한 뒤 다시 여세요.",
"AutomationEditorInvalidWorkspace": "기존 작업 디렉터리를 선택하세요.",
"AutomationEditorModelControls": "입력하여 필터 · ↑↓ 이동 · Enter 선택 · Esc 뒤로",
"AutomationEditorPausedPreview": "일시 정지 · 예약된 실행 없음",
"AutomationEditorInvalidSchedule": "잘못된 일정: {error}",
"AutomationEditorMonday": "월",
"AutomationEditorTuesday": "화",
"AutomationEditorWednesday": "수",
"AutomationEditorThursday": "목",
"AutomationEditorFriday": "금",
"AutomationEditorSaturday": "토",
"AutomationEditorSunday": "일"
}
+36 -2
View File
@@ -1664,7 +1664,7 @@
"AutomationManagerUnavailable": "O gerenciador de automações não está disponível nesta sessão.",
"AutomationListFailed": "Não foi possível listar as automações: {error}",
"AutomationActionFailed": "Não foi possível {action} a automação {id}: {error}",
"AutomationEmpty": "Não há automações agendadas. Use a ferramenta `automation` para criar uma.",
"AutomationEmpty": "Nenhuma automação agendada. Escolha Nova automação.",
"AutomationListHeading": "Automações agendadas",
"AutomationScopeNote": "As automações são suas, não deste repositório — elas seguem você em todos os repositórios.",
"AutomationNoun": "Automação",
@@ -2041,5 +2041,39 @@
"LaunchSeeAllSessions": "Ver todas as sessões…",
"LaunchNoRecentSessions": "Ainda não há sessões recentes — digite abaixo para começar.",
"LaunchResumeFailed": "Falha ao retomar: {error}",
"ReceiptSessionHooks": "hooks {count}"
"ReceiptSessionHooks": "hooks {count}",
"AutomationEditorNew": "Nova automação",
"AutomationEditorEdit": "Editar automação",
"AutomationEditorSchedule": "Agendamento",
"AutomationEditorDaily": "Diário",
"AutomationEditorWeekly": "Semanal",
"AutomationEditorHourly": "A cada hora",
"AutomationEditorOnce": "Uma vez",
"AutomationEditorCustom": "Personalizado",
"AutomationEditorTime": "Hora (HH:MM)",
"AutomationEditorDate": "Data (YYYY-MM-DD)",
"AutomationEditorDays": "Dias",
"AutomationEditorLocalTime": "Fuso horário: local ({zone})",
"AutomationEditorProvider": "O modelo fixa seu provedor. Configuração atual: {provider}.",
"AutomationEditorDefaultModel": "Modelo padrão",
"AutomationEditorInheritedProvider": "padrões do sistema",
"AutomationEditorEnabled": "Ativada",
"AutomationEditorControls": "Tab campos · Enter editar · ↑↓ ajustar · Ctrl+A selecionar tudo",
"AutomationEditorPromptControls": "Enter nova linha · Tab próximo campo · Ctrl+A selecionar tudo",
"AutomationEditorSaveFailed": "Não foi possível salvar: {error}",
"AutomationEditorBusy": "O armazenamento está ocupado. Tente salvar novamente.",
"AutomationEditorSaved": "{name} salvo",
"AutomationEditorPreviewUnavailable": "Próxima execução indisponível; o agendamento atual será mantido",
"AutomationEditorConflict": "A automação mudou. Cancele e abra novamente.",
"AutomationEditorInvalidWorkspace": "Escolha um diretório de trabalho existente.",
"AutomationEditorModelControls": "Digite para filtrar · ↑↓ mover · Enter escolher · Esc voltar",
"AutomationEditorPausedPreview": "Pausada · nenhuma execução agendada",
"AutomationEditorInvalidSchedule": "Agendamento inválido: {error}",
"AutomationEditorMonday": "Seg",
"AutomationEditorTuesday": "Ter",
"AutomationEditorWednesday": "Qua",
"AutomationEditorThursday": "Qui",
"AutomationEditorFriday": "Sex",
"AutomationEditorSaturday": "Sáb",
"AutomationEditorSunday": "Dom"
}
+36 -2
View File
@@ -1664,7 +1664,7 @@
"AutomationManagerUnavailable": "Менеджер автоматизаций недоступен в этом сеансе.",
"AutomationListFailed": "Не удалось получить список автоматизаций: {error}",
"AutomationActionFailed": "Не удалось {action} автоматизацию {id}: {error}",
"AutomationEmpty": "Нет запланированных автоматизаций. Создать новую можно с помощью инструмента `automation`.",
"AutomationEmpty": "Нет запланированных автоматизаций. Выберите «Новая автоматизация».",
"AutomationListHeading": "Запланированные автоматизации",
"AutomationScopeNote": "Автоматизации принадлежат вам, а не этому репозиторию — они следуют за вами во все репозитории.",
"AutomationNoun": "Автоматизация",
@@ -2041,5 +2041,39 @@
"LaunchSeeAllSessions": "Показать все сессии…",
"LaunchNoRecentSessions": "Пока нет недавних сессий — введите ниже, чтобы начать.",
"LaunchResumeFailed": "Не удалось возобновить: {error}",
"ReceiptSessionHooks": "хуков: {count}"
"ReceiptSessionHooks": "хуков: {count}",
"AutomationEditorNew": "Новая автоматизация",
"AutomationEditorEdit": "Изменить автоматизацию",
"AutomationEditorSchedule": "Расписание",
"AutomationEditorDaily": "Ежедневно",
"AutomationEditorWeekly": "Еженедельно",
"AutomationEditorHourly": "Каждый час",
"AutomationEditorOnce": "Один раз",
"AutomationEditorCustom": "Другое",
"AutomationEditorTime": "Время (HH:MM)",
"AutomationEditorDate": "Дата (YYYY-MM-DD)",
"AutomationEditorDays": "Дни",
"AutomationEditorLocalTime": "Часовой пояс: местный ({zone})",
"AutomationEditorProvider": "Выбор модели закрепляет провайдера. Текущая конфигурация: {provider}.",
"AutomationEditorDefaultModel": "Модель по умолчанию",
"AutomationEditorInheritedProvider": "настройки среды выполнения",
"AutomationEditorEnabled": "Включено",
"AutomationEditorControls": "Tab поля · Enter правка · ↑↓ настройка · Ctrl+A выделить всё",
"AutomationEditorPromptControls": "Enter новая строка · Tab следующее поле · Ctrl+A выделить всё",
"AutomationEditorSaveFailed": "Не удалось сохранить: {error}",
"AutomationEditorBusy": "Хранилище автоматизаций занято. Повторите сохранение.",
"AutomationEditorSaved": "Сохранено: {name}",
"AutomationEditorPreviewUnavailable": "Следующий запуск неизвестен; текущее расписание сохранится",
"AutomationEditorConflict": "Автоматизация изменилась. Отмените и откройте заново.",
"AutomationEditorInvalidWorkspace": "Выберите существующий рабочий каталог.",
"AutomationEditorModelControls": "Ввод для фильтра · ↑↓ перемещение · Enter выбор · Esc назад",
"AutomationEditorPausedPreview": "Приостановлено · запуск не запланирован",
"AutomationEditorInvalidSchedule": "Неверное расписание: {error}",
"AutomationEditorMonday": "Пн",
"AutomationEditorTuesday": "Вт",
"AutomationEditorWednesday": "Ср",
"AutomationEditorThursday": "Чт",
"AutomationEditorFriday": "Пт",
"AutomationEditorSaturday": "Сб",
"AutomationEditorSunday": "Вс"
}
+36 -2
View File
@@ -1664,7 +1664,7 @@
"AutomationManagerUnavailable": "Менеджер автоматизацій недоступний у цьому сеансі.",
"AutomationListFailed": "Не вдалося отримати список автоматизацій: {error}",
"AutomationActionFailed": "Не вдалося {action} автоматизацію {id}: {error}",
"AutomationEmpty": "Немає запланованих автоматизацій. Створити нову можна за допомогою інструмента `automation`.",
"AutomationEmpty": "Немає запланованих автоматизацій. Виберіть «Нова автоматизація».",
"AutomationListHeading": "Заплановані автоматизації",
"AutomationScopeNote": "Автоматизації належать вам, а не цьому репозиторію — вони йдуть за вами в кожен репозиторій.",
"AutomationNoun": "Автоматизація",
@@ -2041,5 +2041,39 @@
"LaunchSeeAllSessions": "Показати всі сесії…",
"LaunchNoRecentSessions": "Поки немає нещодавніх сесій — введіть нижче, щоб почати.",
"LaunchResumeFailed": "Не вдалося відновити: {error}",
"ReceiptSessionHooks": "гачків: {count}"
"ReceiptSessionHooks": "гачків: {count}",
"AutomationEditorNew": "Нова автоматизація",
"AutomationEditorEdit": "Редагувати автоматизацію",
"AutomationEditorSchedule": "Розклад",
"AutomationEditorDaily": "Щодня",
"AutomationEditorWeekly": "Щотижня",
"AutomationEditorHourly": "Щогодини",
"AutomationEditorOnce": "Один раз",
"AutomationEditorCustom": "Власний",
"AutomationEditorTime": "Час (HH:MM)",
"AutomationEditorDate": "Дата (YYYY-MM-DD)",
"AutomationEditorDays": "Дні",
"AutomationEditorLocalTime": "Часовий пояс: місцевий ({zone})",
"AutomationEditorProvider": "Вибір моделі закріплює провайдера. Поточна конфігурація: {provider}.",
"AutomationEditorDefaultModel": "Типова модель",
"AutomationEditorInheritedProvider": "типові параметри середовища",
"AutomationEditorEnabled": "Увімкнено",
"AutomationEditorControls": "Tab поля · Enter редагувати · ↑↓ змінити · Ctrl+A вибрати все",
"AutomationEditorPromptControls": "Enter новий рядок · Tab наступне поле · Ctrl+A вибрати все",
"AutomationEditorSaveFailed": "Не вдалося зберегти: {error}",
"AutomationEditorBusy": "Сховище автоматизацій зайняте. Спробуйте зберегти ще раз.",
"AutomationEditorSaved": "Збережено {name}",
"AutomationEditorPreviewUnavailable": "Наступний запуск невідомий; поточний розклад буде збережено",
"AutomationEditorConflict": "Автоматизація змінилася. Скасуйте та відкрийте знову.",
"AutomationEditorInvalidWorkspace": "Виберіть наявний робочий каталог.",
"AutomationEditorModelControls": "Введення для фільтра · ↑↓ рух · Enter вибір · Esc назад",
"AutomationEditorPausedPreview": "Призупинено · запуск не заплановано",
"AutomationEditorInvalidSchedule": "Неприпустимий розклад: {error}",
"AutomationEditorMonday": "Пн",
"AutomationEditorTuesday": "Вт",
"AutomationEditorWednesday": "Ср",
"AutomationEditorThursday": "Чт",
"AutomationEditorFriday": "Пт",
"AutomationEditorSaturday": "Сб",
"AutomationEditorSunday": "Нд"
}
+36 -2
View File
@@ -1664,7 +1664,7 @@
"AutomationManagerUnavailable": "Trình quản lý tự động hóa không khả dụng trong phiên này.",
"AutomationListFailed": "Không thể liệt kê các tự động hóa: {error}",
"AutomationActionFailed": "Không thể {action} tự động hóa {id}: {error}",
"AutomationEmpty": "Không có tự động hóa nào đã lên lịch. Dùng công cụ `automation` để tạo một tự động hóa.",
"AutomationEmpty": "Chưa có tự động hóa được lên lịch. Chọn Tạo tự động hóa.",
"AutomationListHeading": "Tự động hóa đã lên lịch",
"AutomationScopeNote": "Các tự động hóa là của bạn, không phải của kho này — chúng theo bạn vào mọi kho.",
"AutomationNoun": "Tự động hóa",
@@ -2041,5 +2041,39 @@
"LaunchSeeAllSessions": "Xem tất cả các phiên…",
"LaunchNoRecentSessions": "Chưa có phiên gần đây — nhập bên dưới để bắt đầu.",
"LaunchResumeFailed": "Tiếp tục thất bại: {error}",
"ReceiptSessionHooks": "hook {count}"
"ReceiptSessionHooks": "hook {count}",
"AutomationEditorNew": "Tạo tự động hóa",
"AutomationEditorEdit": "Sửa tự động hóa",
"AutomationEditorSchedule": "Lịch chạy",
"AutomationEditorDaily": "Hằng ngày",
"AutomationEditorWeekly": "Hằng tuần",
"AutomationEditorHourly": "Hằng giờ",
"AutomationEditorOnce": "Một lần",
"AutomationEditorCustom": "Tùy chỉnh",
"AutomationEditorTime": "Giờ (HH:MM)",
"AutomationEditorDate": "Ngày (YYYY-MM-DD)",
"AutomationEditorDays": "Các ngày",
"AutomationEditorLocalTime": "Múi giờ: cục bộ ({zone})",
"AutomationEditorProvider": "Chọn mô hình sẽ cố định nhà cung cấp. Cấu hình hiện tại: {provider}.",
"AutomationEditorDefaultModel": "Mô hình mặc định",
"AutomationEditorInheritedProvider": "mặc định của môi trường chạy",
"AutomationEditorEnabled": "Bật",
"AutomationEditorControls": "Tab trường · Enter sửa · ↑↓ điều chỉnh · Ctrl+A chọn tất cả",
"AutomationEditorPromptControls": "Enter xuống dòng · Tab trường tiếp theo · Ctrl+A chọn tất cả",
"AutomationEditorSaveFailed": "Không thể lưu: {error}",
"AutomationEditorBusy": "Kho tự động hóa đang bận. Hãy lưu lại.",
"AutomationEditorSaved": "Đã lưu {name}",
"AutomationEditorPreviewUnavailable": "Chưa biết lần chạy tiếp theo; sẽ giữ nguyên lịch hiện tại",
"AutomationEditorConflict": "Tự động hóa đã thay đổi. Hủy rồi mở lại.",
"AutomationEditorInvalidWorkspace": "Chọn thư mục làm việc đã tồn tại.",
"AutomationEditorModelControls": "Nhập để lọc · ↑↓ di chuyển · Enter chọn · Esc quay lại",
"AutomationEditorPausedPreview": "Đã tạm dừng · không có lần chạy đã lên lịch",
"AutomationEditorInvalidSchedule": "Lịch không hợp lệ: {error}",
"AutomationEditorMonday": "T2",
"AutomationEditorTuesday": "T3",
"AutomationEditorWednesday": "T4",
"AutomationEditorThursday": "T5",
"AutomationEditorFriday": "T6",
"AutomationEditorSaturday": "T7",
"AutomationEditorSunday": "CN"
}
+36 -2
View File
@@ -1664,7 +1664,7 @@
"AutomationManagerUnavailable": "此会话无法使用自动化管理器。",
"AutomationListFailed": "无法列出自动化: {error}",
"AutomationActionFailed": "无法{action}自动化 {id}: {error}",
"AutomationEmpty": "没有已计划的自动化。可使用 `automation` 工具创建。",
"AutomationEmpty": "暂无计划的自动化。请选择“新建自动化”。",
"AutomationListHeading": "已计划的自动化",
"AutomationScopeNote": "自动化属于你,而不是这个仓库——它们会跟随你进入每个仓库。",
"AutomationNoun": "自动化",
@@ -2041,5 +2041,39 @@
"LaunchSeeAllSessions": "查看全部会话…",
"LaunchNoRecentSessions": "暂无最近会话 — 在下方输入以开始。",
"LaunchResumeFailed": "恢复失败:{error}",
"ReceiptSessionHooks": "钩子 {count}"
"ReceiptSessionHooks": "钩子 {count}",
"AutomationEditorNew": "新建自动化",
"AutomationEditorEdit": "编辑自动化",
"AutomationEditorSchedule": "运行计划",
"AutomationEditorDaily": "每天",
"AutomationEditorWeekly": "每周",
"AutomationEditorHourly": "每小时",
"AutomationEditorOnce": "仅一次",
"AutomationEditorCustom": "自定义",
"AutomationEditorTime": "时间 (HH:MM)",
"AutomationEditorDate": "日期 (YYYY-MM-DD)",
"AutomationEditorDays": "星期",
"AutomationEditorLocalTime": "时区:本地 ({zone})",
"AutomationEditorProvider": "选择模型会固定其提供商。当前配置:{provider}。",
"AutomationEditorDefaultModel": "默认模型",
"AutomationEditorInheritedProvider": "运行时默认值",
"AutomationEditorEnabled": "启用",
"AutomationEditorControls": "Tab 切换字段 · Enter 编辑 · ↑↓ 调整 · Ctrl+A 全选",
"AutomationEditorPromptControls": "Enter 换行 · Tab 下一字段 · Ctrl+A 全选",
"AutomationEditorSaveFailed": "无法保存:{error}",
"AutomationEditorBusy": "自动化存储正忙。请重新保存。",
"AutomationEditorSaved": "已保存 {name}",
"AutomationEditorPreviewUnavailable": "下次运行时间不可用;将保留现有计划",
"AutomationEditorConflict": "自动化已发生更改。请取消后重新打开。",
"AutomationEditorInvalidWorkspace": "请选择已有的工作目录。",
"AutomationEditorModelControls": "输入筛选 · ↑↓ 移动 · Enter 选择 · Esc 返回",
"AutomationEditorPausedPreview": "已暂停 · 无计划运行",
"AutomationEditorInvalidSchedule": "无效的计划:{error}",
"AutomationEditorMonday": "周一",
"AutomationEditorTuesday": "周二",
"AutomationEditorWednesday": "周三",
"AutomationEditorThursday": "周四",
"AutomationEditorFriday": "周五",
"AutomationEditorSaturday": "周六",
"AutomationEditorSunday": "周日"
}
+36 -2
View File
@@ -198,7 +198,7 @@
"AutomationManagerUnavailable": "此工作階段無法使用自動化管理器。",
"AutomationListFailed": "無法列出自動化: {error}",
"AutomationActionFailed": "無法{action}自動化 {id}: {error}",
"AutomationEmpty": "沒有已計劃的自動化。可使用 `automation` 工具建立。",
"AutomationEmpty": "目前沒有排定的自動化。請選擇「新增自動化」。",
"AutomationListHeading": "已計劃的自動化",
"AutomationScopeNote": "自動化屬於你,而不是這個儲存庫——它們會跟著你進入每個儲存庫。",
"AutomationNoun": "自動化",
@@ -2041,5 +2041,39 @@
"LaunchSeeAllSessions": "查看全部會話…",
"LaunchNoRecentSessions": "暫無最近會話 — 在下方輸入以開始。",
"LaunchResumeFailed": "恢復失敗:{error}",
"ReceiptSessionHooks": "掛鉤 {count}"
"ReceiptSessionHooks": "掛鉤 {count}",
"AutomationEditorNew": "新增自動化",
"AutomationEditorEdit": "編輯自動化",
"AutomationEditorSchedule": "執行排程",
"AutomationEditorDaily": "每天",
"AutomationEditorWeekly": "每週",
"AutomationEditorHourly": "每小時",
"AutomationEditorOnce": "僅一次",
"AutomationEditorCustom": "自訂",
"AutomationEditorTime": "時間 (HH:MM)",
"AutomationEditorDate": "日期 (YYYY-MM-DD)",
"AutomationEditorDays": "星期",
"AutomationEditorLocalTime": "時區:本機 ({zone})",
"AutomationEditorProvider": "選擇模型會固定其供應商。目前設定:{provider}。",
"AutomationEditorDefaultModel": "預設模型",
"AutomationEditorInheritedProvider": "執行階段預設值",
"AutomationEditorEnabled": "啟用",
"AutomationEditorControls": "Tab 切換欄位 · Enter 編輯 · ↑↓ 調整 · Ctrl+A 全選",
"AutomationEditorPromptControls": "Enter 換行 · Tab 下一欄位 · Ctrl+A 全選",
"AutomationEditorSaveFailed": "無法儲存:{error}",
"AutomationEditorBusy": "自動化儲存空間忙碌中。請重新儲存。",
"AutomationEditorSaved": "已儲存 {name}",
"AutomationEditorPreviewUnavailable": "下次執行時間不明;將保留現有排程",
"AutomationEditorConflict": "自動化已變更。請取消後重新開啟。",
"AutomationEditorInvalidWorkspace": "請選擇現有的工作目錄。",
"AutomationEditorModelControls": "輸入篩選 · ↑↓ 移動 · Enter 選擇 · Esc 返回",
"AutomationEditorPausedPreview": "已暫停 · 無排定的執行",
"AutomationEditorInvalidSchedule": "無效的排程:{error}",
"AutomationEditorMonday": "週一",
"AutomationEditorTuesday": "週二",
"AutomationEditorWednesday": "週三",
"AutomationEditorThursday": "週四",
"AutomationEditorFriday": "週五",
"AutomationEditorSaturday": "週六",
"AutomationEditorSunday": "週日"
}
+145 -8
View File
@@ -25,7 +25,9 @@ use crate::utils::spawn_supervised;
/// Current automation record schema. `pub(crate)` so the Operate keepalive
/// can build a fixed-id record directly (no create/delete id swap).
pub(crate) const CURRENT_AUTOMATION_SCHEMA_VERSION: u32 = 1;
// v2 pins provider identity. Older runtimes must reject a pinned definition
// instead of silently sending its model through their current provider.
pub(crate) const CURRENT_AUTOMATION_SCHEMA_VERSION: u32 = 2;
const CURRENT_RUN_SCHEMA_VERSION: u32 = 1;
const CURRENT_TRIGGER_SCHEMA_VERSION: u32 = 1;
const DEFAULT_AUTOMATION_MODE: &str = "agent";
@@ -153,6 +155,11 @@ pub struct AutomationRecord {
pub cwds: Vec<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// Exact provider provenance for a pinned model; absent on legacy records.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_provider_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -233,6 +240,10 @@ pub struct CreateAutomationRequest {
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub model_provider: Option<String>,
#[serde(default)]
pub model_provider_id: Option<String>,
#[serde(default)]
pub mode: Option<String>,
#[serde(default)]
pub allow_shell: Option<bool>,
@@ -253,6 +264,8 @@ pub struct UpdateAutomationRequest {
pub rrule: Option<String>,
pub cwds: Option<Vec<PathBuf>>,
pub model: Option<String>,
pub model_provider: Option<String>,
pub model_provider_id: Option<String>,
pub mode: Option<String>,
pub allow_shell: Option<bool>,
pub trust_mode: Option<bool>,
@@ -409,7 +422,7 @@ impl AutomationSchedule {
}
}
fn next_after_with_anchor(
pub(crate) fn next_after_with_anchor(
&self,
after: DateTime<Utc>,
anchor_reference: DateTime<Utc>,
@@ -978,6 +991,8 @@ impl AutomationManager {
rrule: req.rrule.trim().to_ascii_uppercase(),
cwds: req.cwds,
model: normalize_optional_string(req.model),
model_provider: normalize_optional_string(req.model_provider),
model_provider_id: normalize_optional_string(req.model_provider_id),
mode: normalize_optional_string(req.mode),
allow_shell: req.allow_shell,
trust_mode: req.trust_mode,
@@ -1011,6 +1026,18 @@ impl AutomationManager {
}
pub fn save_automation(&self, record: &AutomationRecord) -> Result<()> {
if record.model_provider.is_some() || record.model_provider_id.is_some() {
if record
.model
.as_deref()
.is_none_or(|model| model.trim().is_empty())
{
bail!("A pinned automation provider requires an explicit model");
}
let mut record = record.clone();
record.schema_version = record.schema_version.max(CURRENT_AUTOMATION_SCHEMA_VERSION);
return write_json_atomic(&self.automation_path(&record.id)?, &record);
}
write_json_atomic(&self.automation_path(&record.id)?, record)
}
@@ -1047,6 +1074,7 @@ impl AutomationManager {
req: UpdateAutomationRequest,
) -> Result<AutomationRecord> {
let mut existing = self.get_automation(id)?;
let schedule_changed = req.rrule.is_some() || req.status.is_some();
if let Some(name) = req.name {
if name.trim().is_empty() {
@@ -1064,11 +1092,6 @@ impl AutomationManager {
let normalized = rrule.trim().to_ascii_uppercase();
AutomationSchedule::parse_rrule(&normalized)?;
existing.rrule = normalized;
if matches!(existing.status, AutomationStatus::Active) {
let schedule = AutomationSchedule::parse_rrule(&existing.rrule)?;
existing.next_run_at =
Some(schedule.next_after_with_anchor(Utc::now(), existing.created_at)?);
}
}
if let Some(cwds) = req.cwds {
existing.cwds = cwds;
@@ -1076,6 +1099,12 @@ impl AutomationManager {
if let Some(model) = req.model {
existing.model = normalize_optional_string(Some(model));
}
if let Some(provider) = req.model_provider {
existing.model_provider = normalize_optional_string(Some(provider));
}
if let Some(provider_id) = req.model_provider_id {
existing.model_provider_id = normalize_optional_string(Some(provider_id));
}
if let Some(mode) = req.mode {
existing.mode = normalize_optional_string(Some(mode));
}
@@ -1093,7 +1122,11 @@ impl AutomationManager {
}
if let Some(status) = req.status {
existing.status = status;
if matches!(status, AutomationStatus::Paused) {
}
// Evaluate the final status once: editing a schedule and pausing it is
// one atomic update, and must not first schedule an active run.
if schedule_changed {
if matches!(existing.status, AutomationStatus::Paused) {
existing.next_run_at = None;
} else {
let schedule = AutomationSchedule::parse_rrule(&existing.rrule)?;
@@ -1102,6 +1135,10 @@ impl AutomationManager {
}
}
if existing.model_provider.is_some() || existing.model_provider_id.is_some() {
existing.schema_version = CURRENT_AUTOMATION_SCHEMA_VERSION;
}
existing.updated_at = Utc::now();
self.save_automation(&existing)?;
Ok(existing)
@@ -1561,6 +1598,8 @@ async fn enqueue_run_task(
let new_task = NewTaskRequest {
prompt: automation.prompt.clone(),
model: automation.model.clone(),
model_provider: automation.model_provider.clone(),
model_provider_id: automation.model_provider_id.clone(),
workspace,
mode: Some(automation.task_mode()),
allow_shell: Some(automation.task_allow_shell()),
@@ -1692,6 +1731,8 @@ async fn fire_due_triggers_shared(
let new_task = NewTaskRequest {
prompt: trigger.message.clone(),
model: None,
model_provider: None,
model_provider_id: None,
workspace,
mode: Some("agent".to_string()),
allow_shell: Some(false),
@@ -2155,6 +2196,8 @@ mod tests {
rrule: "FREQ=HOURLY;INTERVAL=1".to_string(),
cwds: Vec::new(),
model: None,
model_provider: None,
model_provider_id: None,
mode: mode.map(ToString::to_string),
allow_shell,
trust_mode,
@@ -2553,6 +2596,8 @@ mod tests {
rrule: "FREQ=HOURLY;INTERVAL=1".to_string(),
cwds: Vec::new(),
model: Some(" scheduled-model ".to_string()),
model_provider: None,
model_provider_id: None,
mode: None,
allow_shell: None,
trust_mode: None,
@@ -2595,6 +2640,8 @@ mod tests {
rrule: "FREQ=HOURLY;INTERVAL=1".to_string(),
cwds: Vec::new(),
model: None,
model_provider: None,
model_provider_id: None,
mode: None,
allow_shell: None,
trust_mode: None,
@@ -2742,6 +2789,94 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn automation_provider_pin_survives_active_route_change_and_legacy_inherits() -> Result<()>
{
let _env = crate::test_support::lock_test_env();
let root = tempfile::tempdir()?;
let mut config: crate::config::Config = toml::from_str(
r#"
provider = "first"
[providers.first]
kind = "openai-compatible"
base_url = "http://127.0.0.1:9/first/v1"
api_key = "fixture-first"
model = "private-model"
[providers.second]
kind = "openai-compatible"
base_url = "http://127.0.0.1:9/second/v1"
api_key = "fixture-second"
model = "private-model"
"#,
)?;
let automations = AutomationManager::open(root.path().join("schedules"))?;
let mut record = automation_record_with_settings(None, None, None, None);
record.schema_version = 1;
record.model = Some("private-model".to_string());
record.model_provider = Some("custom".to_string());
record.model_provider_id = Some("first".to_string());
automations.save_automation(&record)?;
let record = automations.get_automation(&record.id)?;
assert_eq!(
record.schema_version, 2,
"a pin cannot be ignored by an older reader"
);
let runtime = crate::runtime_threads::RuntimeThreadManager::open(
config.clone(),
root.path().to_path_buf(),
crate::runtime_threads::RuntimeThreadManagerConfig::from_task_data_dir(
root.path().join("runtime"),
),
)?;
config.provider = Some("second".to_string());
runtime.reload_config(config).await?;
let tasks = TaskManager::start_with_executor(
automation_task_config(root.path().join("tasks")),
std::sync::Arc::new(AutomationNoopExecutor),
)
.await?;
let mut run = queued_run_for(&record);
enqueue_run_task(&record, &mut run, &tasks).await;
let task = tasks
.get_task(run.task_id.as_deref().expect("task id"))
.await?;
let task: crate::task_manager::TaskRecord =
serde_json::from_slice(&serde_json::to_vec(&task)?)?;
assert_eq!(task.schema_version, 3);
let thread = runtime
.create_thread(ExecutionTask::from(&task).thread_request())
.await?;
assert_eq!(thread.model, "private-model");
assert_eq!(thread.model_provider.as_deref(), Some("custom"));
assert_eq!(thread.model_provider_id.as_deref(), Some("first"));
let mut legacy = serde_json::to_value(&record)?;
let object = legacy.as_object_mut().unwrap();
object.remove("model_provider");
object.remove("model_provider_id");
object.insert("schema_version".to_string(), serde_json::json!(1));
let legacy: AutomationRecord = serde_json::from_value(legacy)?;
assert_eq!(legacy.model_provider, None);
let mut run = queued_run_for(&legacy);
enqueue_run_task(&legacy, &mut run, &tasks).await;
let task = tasks
.get_task(run.task_id.as_deref().expect("legacy task id"))
.await?;
let mut value = serde_json::to_value(&task)?;
value.as_object_mut().unwrap().remove("model_provider");
value.as_object_mut().unwrap().remove("model_provider_id");
value["schema_version"] = serde_json::json!(2);
let task: crate::task_manager::TaskRecord = serde_json::from_value(value)?;
let thread = runtime
.create_thread(ExecutionTask::from(&task).thread_request())
.await?;
assert_eq!(thread.model_provider_id.as_deref(), Some("second"));
assert_eq!(thread.model, "private-model");
tasks.shutdown();
Ok(())
}
#[tokio::test]
async fn delayed_trigger_fires_task_with_same_owner_and_skips_legacy_ownerless() -> Result<()> {
let tempdir = tempfile::tempdir().expect("tempdir");
@@ -3078,6 +3213,8 @@ mod tests {
rrule: "FREQ=HOURLY;INTERVAL=1".to_string(),
cwds: Vec::new(),
model: None,
model_provider: None,
model_provider_id: None,
mode: None,
allow_shell: None,
trust_mode: None,
+70 -4
View File
@@ -1965,6 +1965,40 @@ pub enum MessageId {
AutomationActionResumed,
AutomationNextLabel,
AutomationNameLabel,
AutomationEditorNew,
AutomationEditorEdit,
AutomationEditorSchedule,
AutomationEditorDaily,
AutomationEditorWeekly,
AutomationEditorHourly,
AutomationEditorOnce,
AutomationEditorCustom,
AutomationEditorTime,
AutomationEditorDate,
AutomationEditorDays,
AutomationEditorLocalTime,
AutomationEditorProvider,
AutomationEditorDefaultModel,
AutomationEditorInheritedProvider,
AutomationEditorEnabled,
AutomationEditorControls,
AutomationEditorPromptControls,
AutomationEditorSaveFailed,
AutomationEditorBusy,
AutomationEditorSaved,
AutomationEditorPreviewUnavailable,
AutomationEditorConflict,
AutomationEditorInvalidWorkspace,
AutomationEditorModelControls,
AutomationEditorPausedPreview,
AutomationEditorInvalidSchedule,
AutomationEditorMonday,
AutomationEditorTuesday,
AutomationEditorWednesday,
AutomationEditorThursday,
AutomationEditorFriday,
AutomationEditorSaturday,
AutomationEditorSunday,
AutomationPromptLabel,
AutomationCwdLabel,
AutomationModeLabel,
@@ -4018,6 +4052,40 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::AutomationActionResumed,
MessageId::AutomationNextLabel,
MessageId::AutomationNameLabel,
MessageId::AutomationEditorNew,
MessageId::AutomationEditorEdit,
MessageId::AutomationEditorSchedule,
MessageId::AutomationEditorDaily,
MessageId::AutomationEditorWeekly,
MessageId::AutomationEditorHourly,
MessageId::AutomationEditorOnce,
MessageId::AutomationEditorCustom,
MessageId::AutomationEditorTime,
MessageId::AutomationEditorDate,
MessageId::AutomationEditorDays,
MessageId::AutomationEditorLocalTime,
MessageId::AutomationEditorProvider,
MessageId::AutomationEditorDefaultModel,
MessageId::AutomationEditorInheritedProvider,
MessageId::AutomationEditorEnabled,
MessageId::AutomationEditorControls,
MessageId::AutomationEditorPromptControls,
MessageId::AutomationEditorSaveFailed,
MessageId::AutomationEditorBusy,
MessageId::AutomationEditorSaved,
MessageId::AutomationEditorPreviewUnavailable,
MessageId::AutomationEditorConflict,
MessageId::AutomationEditorInvalidWorkspace,
MessageId::AutomationEditorModelControls,
MessageId::AutomationEditorPausedPreview,
MessageId::AutomationEditorInvalidSchedule,
MessageId::AutomationEditorMonday,
MessageId::AutomationEditorTuesday,
MessageId::AutomationEditorWednesday,
MessageId::AutomationEditorThursday,
MessageId::AutomationEditorFriday,
MessageId::AutomationEditorSaturday,
MessageId::AutomationEditorSunday,
MessageId::AutomationPromptLabel,
MessageId::AutomationCwdLabel,
MessageId::AutomationModeLabel,
@@ -4781,10 +4849,8 @@ mod tests {
.keys()
.filter(|key| key.starts_with("Automation"))
.collect::<Vec<_>>();
// AUTOMATION-VISIBILITY §2: the band slot and typed receipt cards
// added 10 keys and retired 3 bare-prose receipts (42 → 49); the
// automations room added its cancel hint and scope note (→ 51).
assert_eq!(automation_keys.len(), 51);
// New room/editor strings are part of the same complete-pack contract.
assert!(!automation_keys.is_empty());
for locale in Locale::shipped_complete() {
let pack = raw_locale_messages(*locale);
+2
View File
@@ -1146,6 +1146,8 @@ pub fn upsert_keepalive(
rrule: OPERATE_KEEPALIVE_RRULE.to_string(),
cwds: Vec::new(),
model: Some(OPERATE_LEAD_MODEL.to_string()),
model_provider: None,
model_provider_id: None,
mode: Some("operate".to_string()),
allow_shell: Some(true),
trust_mode: Some(false),
+1 -1
View File
@@ -1523,7 +1523,7 @@ async fn create_task(
if req.workspace.is_none() {
req.workspace = Some(state.workspace.clone());
}
if req.model.is_none() {
if req.model.is_none() && req.model_provider.is_none() && req.model_provider_id.is_none() {
req.model = Some(runtime_request_model(&state.config.read(), None));
}
let task = state
+22
View File
@@ -1401,6 +1401,28 @@ async fn omitted_runtime_models_use_the_active_provider_default() -> Result<()>
return Ok(());
};
// A provider pin cannot borrow the active provider's default model.
// It must reach the same explicit-model guard as direct task creation.
for pin in [
json!({"model_provider": "openai"}),
json!({"model_provider_id": "other-route"}),
] {
let mut request = pin;
request["prompt"] = json!("reject missing pinned model");
let rejected = crate::tls::reqwest_client()
.post(format!("http://{addr}/v1/tasks"))
.json(&request)
.send()
.await?;
assert_eq!(rejected.status(), StatusCode::BAD_REQUEST);
assert!(
rejected
.text()
.await?
.contains("pinned task provider requires an explicit model")
);
}
let created: serde_json::Value = crate::tls::reqwest_client()
.post(format!("http://{addr}/v1/tasks"))
.json(&json!({ "prompt": format!("{label} omitted model") }))
+77 -33
View File
@@ -1,8 +1,8 @@
//! Persistent background task manager for Codewhale agent work.
//!
//! Tasks are durable across restarts and execute with a bounded worker pool.
//! Execution stays DeepSeek-only and now links every task to runtime
//! thread/turn records for unified timelines.
//! Execution uses the shared runtime provider route and links every task to
//! runtime thread/turn records for unified timelines.
use std::collections::{HashMap, HashSet, VecDeque};
use std::fs;
@@ -38,10 +38,8 @@ const ARTIFACT_THRESHOLD: usize = 1200;
const TASK_EVENT_CHANNEL_CAPACITY: usize = 256;
const EVENT_CURSOR_BATCH: usize = 256;
const EVENT_CATCHUP_POLL: Duration = Duration::from_millis(200);
// `lifecycle_seq` is an additive, serde-defaulted field. Keep the durable task
// schema at v2 so a v0.9.1 rollback can ignore it and still open tasks written
// by this build; no existing field changed meaning.
const CURRENT_TASK_SCHEMA_VERSION: u32 = 2;
// v3 adds a provider pin; older executors must not silently ignore it.
const CURRENT_TASK_SCHEMA_VERSION: u32 = 3;
const fn default_task_schema_version() -> u32 {
CURRENT_TASK_SCHEMA_VERSION
@@ -274,6 +272,10 @@ pub struct TaskRecord {
pub id: String,
pub prompt: String,
pub model: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_provider_id: Option<String>,
pub workspace: PathBuf,
pub mode: String,
pub allow_shell: bool,
@@ -329,6 +331,10 @@ pub struct TaskSummary {
pub status: TaskStatus,
pub prompt_summary: String,
pub model: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_provider_id: Option<String>,
pub mode: String,
pub workspace: PathBuf,
pub created_at: DateTime<Utc>,
@@ -358,6 +364,8 @@ impl From<&TaskRecord> for TaskSummary {
status: value.status,
prompt_summary: summarize_text(&value.prompt, TIMELINE_SUMMARY_LIMIT),
model: value.model.clone(),
model_provider: value.model_provider.clone(),
model_provider_id: value.model_provider_id.clone(),
mode: value.mode.clone(),
workspace: value.workspace.clone(),
created_at: value.created_at,
@@ -390,6 +398,10 @@ pub struct TaskCounts {
pub struct NewTaskRequest {
pub prompt: String,
pub model: Option<String>,
#[serde(default)]
pub model_provider: Option<String>,
#[serde(default)]
pub model_provider_id: Option<String>,
pub workspace: Option<PathBuf>,
pub mode: Option<String>,
pub allow_shell: Option<bool>,
@@ -405,6 +417,8 @@ impl NewTaskRequest {
Self {
prompt: prompt.into(),
model: None,
model_provider: None,
model_provider_id: None,
workspace: None,
mode: None,
allow_shell: None,
@@ -580,6 +594,8 @@ pub struct ExecutionTask {
id: String,
prompt: String,
model: String,
model_provider: Option<String>,
model_provider_id: Option<String>,
workspace: PathBuf,
mode_label: String,
allow_shell: bool,
@@ -587,6 +603,40 @@ pub struct ExecutionTask {
auto_approve: bool,
}
impl From<&TaskRecord> for ExecutionTask {
fn from(task: &TaskRecord) -> Self {
Self {
id: task.id.clone(),
prompt: task.prompt.clone(),
model: task.model.clone(),
model_provider: task.model_provider.clone(),
model_provider_id: task.model_provider_id.clone(),
workspace: task.workspace.clone(),
mode_label: task.mode.clone(),
allow_shell: task.allow_shell,
trust_mode: task.trust_mode,
auto_approve: task.auto_approve,
}
}
}
impl ExecutionTask {
pub(crate) fn thread_request(&self) -> CreateThreadRequest {
CreateThreadRequest {
model: Some(self.model.clone()),
model_provider: self.model_provider.clone(),
model_provider_id: self.model_provider_id.clone(),
workspace: Some(self.workspace.clone()),
mode: Some(self.mode_label.clone()),
allow_shell: Some(self.allow_shell),
trust_mode: Some(self.trust_mode),
auto_approve: Some(self.auto_approve),
task_id: Some(self.id.clone()),
..Default::default()
}
}
}
/// Event stream produced by an executor while a task runs.
#[derive(Debug, Clone)]
pub enum TaskExecutionEvent {
@@ -670,7 +720,7 @@ pub trait TaskExecutor: Send + Sync {
) -> TaskExecutionResult;
}
/// Engine-backed executor (DeepSeek-only).
/// Executor backed by the shared runtime and its canonical provider resolver.
pub struct EngineTaskExecutor {
runtime_threads: SharedRuntimeThreadManager,
limits: TaskExecutionLimits,
@@ -696,18 +746,7 @@ impl TaskExecutor for EngineTaskExecutor {
) -> TaskExecutionResult {
let thread = match self
.runtime_threads
.create_thread(CreateThreadRequest {
model: Some(task.model.clone()),
workspace: Some(task.workspace.clone()),
mode: Some(task.mode_label.clone()),
allow_shell: Some(task.allow_shell),
trust_mode: Some(task.trust_mode),
auto_approve: Some(task.auto_approve),
archived: false,
system_prompt: None,
task_id: Some(task.id.clone()),
..Default::default()
})
.create_thread(task.thread_request())
.await
{
Ok(thread) => thread,
@@ -1226,6 +1265,14 @@ impl TaskManager {
if prompt.is_empty() {
bail!("Task prompt cannot be empty");
}
if (req.model_provider.is_some() || req.model_provider_id.is_some())
&& req
.model
.as_deref()
.is_none_or(|model| model.trim().is_empty())
{
bail!("A pinned task provider requires an explicit model");
}
if task_id.len() != 21
|| !task_id.starts_with("task_")
|| !task_id[5..].chars().all(|ch| ch.is_ascii_hexdigit())
@@ -1244,6 +1291,8 @@ impl TaskManager {
id: task_id,
prompt,
model: req.model.unwrap_or_else(|| self.cfg.default_model.clone()),
model_provider: req.model_provider,
model_provider_id: req.model_provider_id,
workspace: match req.workspace {
Some(workspace) => workspace,
None => self.default_workspace().await,
@@ -1620,18 +1669,7 @@ impl TaskManager {
},
);
let request = {
ExecutionTask {
id: task.id.clone(),
prompt: task.prompt.clone(),
model: task.model.clone(),
workspace: task.workspace.clone(),
mode_label: task.mode.clone(),
allow_shell: task.allow_shell,
trust_mode: task.trust_mode,
auto_approve: task.auto_approve,
}
};
let request = ExecutionTask::from(&*task);
let cancel = CancellationToken::new();
state.running_cancel.insert(task_id.clone(), cancel.clone());
@@ -2836,8 +2874,8 @@ mod tests {
.await?;
assert_eq!(created.id, id);
assert_eq!(
created.schema_version, 2,
"the additive lifecycle field must remain rollback-readable"
created.schema_version, 3,
"provider pins require readers that preserve the route"
);
assert_eq!(created.lifecycle_seq, 1);
let collision = manager
@@ -3081,6 +3119,8 @@ mod tests {
id: task_id.clone(),
prompt: "long-running shell work".to_string(),
model: "deepseek-v4-flash".to_string(),
model_provider: None,
model_provider_id: None,
workspace: PathBuf::from("."),
mode: "agent".to_string(),
allow_shell: true,
@@ -3322,6 +3362,8 @@ mod tests {
let req = NewTaskRequest {
prompt: "fix TODOs and write a README".to_string(),
model: None,
model_provider: None,
model_provider_id: None,
workspace: None,
mode: None,
allow_shell: None,
@@ -3740,6 +3782,8 @@ mod tests {
id: "task_0123456789abcdef".to_string(),
prompt: "bound timeline".to_string(),
model: "deepseek-v4-flash".to_string(),
model_provider: None,
model_provider_id: None,
workspace: PathBuf::from("."),
mode: "agent".to_string(),
allow_shell: false,
+16
View File
@@ -166,6 +166,14 @@ impl ToolSpec for AutomationTool {
"cwds".to_string(),
json!({ "type": "array", "items": { "type": "string" }, "description": "Working directories for scheduled runs (action=create/update)." }),
);
properties.insert(
"model_provider".to_string(),
json!({ "type": "string", "description": "Provider kind for the pinned model. Omit to inherit the configured provider." }),
);
properties.insert(
"model_provider_id".to_string(),
json!({ "type": "string", "description": "Exact configured provider id, including named custom routes. Keeps the model on that route." }),
);
properties.insert(
"model".to_string(),
json!({ "type": "string", "description": "Model id for scheduled runs (action=create/update)." }),
@@ -301,6 +309,8 @@ impl AutomationTool {
.map(PathBuf::from)
.collect(),
model: optional_str(input, "model")?.map(ToString::to_string),
model_provider: optional_str(input, "model_provider")?.map(ToString::to_string),
model_provider_id: optional_str(input, "model_provider_id")?.map(ToString::to_string),
mode: optional_str(input, "mode")?.map(ToString::to_string),
allow_shell: optional_bool_value(input, "allow_shell"),
trust_mode: optional_bool_value(input, "trust_mode"),
@@ -393,6 +403,8 @@ impl AutomationTool {
None
},
model: optional_str(input, "model")?.map(ToString::to_string),
model_provider: optional_str(input, "model_provider")?.map(ToString::to_string),
model_provider_id: optional_str(input, "model_provider_id")?.map(ToString::to_string),
mode: optional_str(input, "mode")?.map(ToString::to_string),
allow_shell: optional_bool_value(input, "allow_shell"),
trust_mode: optional_bool_value(input, "trust_mode"),
@@ -468,6 +480,8 @@ fn legacy_action_schema(action: &str) -> Value {
},
"cwds": { "type": "array", "items": { "type": "string" } },
"model": { "type": "string", "description": "Model id for scheduled runs." },
"model_provider": { "type": "string", "description": "Provider kind for the pinned model." },
"model_provider_id": { "type": "string", "description": "Exact configured provider id." },
"mode": { "type": "string", "description": "Task mode for scheduled runs. Defaults to agent when omitted." },
"allow_shell": { "type": "boolean", "default": false },
"trust_mode": { "type": "boolean", "default": false },
@@ -499,6 +513,8 @@ fn legacy_action_schema(action: &str) -> Value {
"rrule": { "type": "string" },
"cwds": { "type": "array", "items": { "type": "string" } },
"model": { "type": "string", "description": "Model id for scheduled runs." },
"model_provider": { "type": "string", "description": "Provider kind for the pinned model." },
"model_provider_id": { "type": "string", "description": "Exact configured provider id." },
"mode": { "type": "string", "description": "Task mode for scheduled runs. Defaults to agent when omitted." },
"allow_shell": { "type": "boolean" },
"trust_mode": { "type": "boolean" },
+12
View File
@@ -224,6 +224,14 @@ impl ToolSpec for TasksTool {
"prompt".to_string(),
json!({ "type": "string", "description": "Work prompt for the durable task (action=create)." }),
);
properties.insert(
"model_provider".to_string(),
json!({ "type": "string", "description": "Provider kind for the pinned model. Omit to inherit the configured provider." }),
);
properties.insert(
"model_provider_id".to_string(),
json!({ "type": "string", "description": "Exact configured provider id, including named custom routes. Keeps the model on that route." }),
);
properties.insert(
"model".to_string(),
json!({ "type": "string", "description": "(action=create)" }),
@@ -377,6 +385,8 @@ fn legacy_action_schema(action: &str) -> Value {
"properties": {
"prompt": { "type": "string", "description": "Work prompt for the durable task." },
"model": { "type": "string" },
"model_provider": { "type": "string", "description": "Provider kind for the pinned model." },
"model_provider_id": { "type": "string", "description": "Exact configured provider id." },
"workspace": { "type": "string", "description": "Workspace path; defaults to current workspace." },
"mode": { "type": "string", "enum": ["agent", "plan", "operate"] },
"allow_shell": { "type": "boolean" },
@@ -461,6 +471,8 @@ impl TasksTool {
let req = NewTaskRequest {
prompt: prompt.clone(),
model: optional_str(input, "model")?.map(ToString::to_string),
model_provider: optional_str(input, "model_provider")?.map(ToString::to_string),
model_provider_id: optional_str(input, "model_provider_id")?.map(ToString::to_string),
workspace: Some(workspace),
mode: optional_str(input, "mode")?.map(ToString::to_string),
// Authority declarations: read strictly. A malformed value that
+2
View File
@@ -316,6 +316,8 @@ mod tests {
rrule: "FREQ=DAILY".to_string(),
cwds: Vec::new(),
model: None,
model_provider: None,
model_provider_id: None,
mode: None,
allow_shell: None,
trust_mode: None,
+24 -1
View File
@@ -17,6 +17,7 @@ use crate::tui::history::{AutomationCell, AutomationCellKind, HistoryCell};
pub(super) async fn handle_action(
app: &mut App,
config: &crate::config::Config,
action: AutomationAction,
task_manager: &SharedTaskManager,
) {
@@ -42,6 +43,7 @@ pub(super) async fn handle_action(
app.view_stack
.push(crate::tui::views::automations::AutomationsView::new(
app,
config,
focus.as_deref(),
));
app.needs_redraw = true;
@@ -313,6 +315,21 @@ fn format_detail(
&display_text(mode),
));
}
if let Some(model) = record.model.as_deref() {
let route = record
.model_provider_id
.as_ref()
.or(record.model_provider.as_ref())
.map_or_else(
|| model.to_string(),
|provider| format!("{provider} / {model}"),
);
lines.push(field(
locale,
MessageId::SetupCardModelLabel,
&display_text(&route),
));
}
if let Some(allow_shell) = record.allow_shell {
lines.push(field(
locale,
@@ -533,6 +550,8 @@ mod tests {
rrule: "FREQ=DAILY".to_string(),
cwds: Vec::new(),
model: None,
model_provider: None,
model_provider_id: None,
mode: None,
allow_shell: None,
trust_mode: None,
@@ -548,7 +567,7 @@ mod tests {
#[test]
fn list_explains_empty_state_and_operator_controls() {
assert!(format_list(Locale::En, &[]).contains("`automation` tool to create one"));
assert!(format_list(Locale::En, &[]).contains("Choose New automation"));
let text = format_list(Locale::En, &[record(AutomationStatus::Paused)]);
assert!(text.contains("auto_1 [paused] Nightly checks"));
assert!(text.contains("next: -"));
@@ -704,6 +723,8 @@ mod tests {
rrule: "FREQ=HOURLY;INTERVAL=1".to_string(),
cwds: Vec::new(),
model: None,
model_provider: None,
model_provider_id: None,
mode: None,
allow_shell: None,
trust_mode: None,
@@ -751,6 +772,8 @@ mod tests {
rrule: "FREQ=HOURLY;INTERVAL=1".to_string(),
cwds: Vec::new(),
model: None,
model_provider: None,
model_provider_id: None,
mode: None,
allow_shell: None,
trust_mode: None,
+2
View File
@@ -1087,6 +1087,8 @@ mod tests {
status,
prompt_summary: "Fix task list output".to_string(),
model: "deepseek-v4-pro".to_string(),
model_provider: None,
model_provider_id: None,
mode: "agent".to_string(),
workspace: PathBuf::from("/tmp"),
created_at: Utc::now(),
+4 -1
View File
@@ -2126,6 +2126,8 @@ pub(crate) async fn apply_command_result(
let request = NewTaskRequest {
prompt: prompt.clone(),
model: Some(app.model.clone()),
model_provider: Some(app.api_provider.as_str().to_string()),
model_provider_id: Some(app.provider_identity_for_persistence().to_string()),
workspace: Some(app.workspace.clone()),
mode: Some(task_mode_label(app.mode).to_string()),
allow_shell: Some(app.allow_shell),
@@ -2214,7 +2216,8 @@ pub(crate) async fn apply_command_result(
refresh_active_task_panel(app, task_manager).await;
}
AppAction::Automation(action) => {
crate::tui::automation_routing::handle_action(app, action, task_manager).await;
crate::tui::automation_routing::handle_action(app, config, action, task_manager)
.await;
}
AppAction::ShellJob(action) => {
handle_shell_job_action(app, action);
+2
View File
@@ -22508,6 +22508,8 @@ mod work_sidebar_projection_tests {
status,
prompt_summary: format!("task {id}"),
model: "deepseek-v4-flash".to_string(),
model_provider: None,
model_provider_id: None,
mode: "agent".to_string(),
workspace: std::path::PathBuf::from("/tmp"),
created_at,
+246 -9
View File
@@ -4,9 +4,11 @@
//! (workflow runs, fleet, extensions): ↑↓ move, Enter opens the detail, Esc
//! backs out, Tab flips list ↔ detail. The actions an automation affords —
//! pause / resume, run now, cancel a live run, delete — are single keys and
//! every one of them goes through the same `/automation …` and `/task cancel`
//! every one of those actions goes through the same `/automation …` and `/task cancel`
//! commands the transcript already accepts, so a keypress here and a typed
//! command leave identical receipts.
//! Create/edit keep a local draft until Save calls the shared manager; Cancel
//! discards that draft. The room then shows the persisted definition and receipt.
//!
//! Automations are user-global (`~/.codewhale/automations`): they follow the
//! person into every repository, which is why the room says where each one
@@ -40,6 +42,9 @@ use crate::palette;
use crate::tui::app::App;
use crate::tui::list_nav::wrap_index;
mod editor;
use editor::{AutomationEditor, EditorAction};
/// Recent runs kept per automation in the detail pane.
const RECENT_RUNS: usize = 5;
@@ -77,12 +82,18 @@ pub struct AutomationsView {
problem: Option<String>,
/// Screen rect of the list body, recorded at render for mouse parity.
list_body: Cell<Rect>,
config: crate::config::Config,
workspace: std::path::PathBuf,
editor: Option<AutomationEditor>,
notice: Option<String>,
new_button: Cell<Rect>,
edit_button: Cell<Rect>,
}
impl AutomationsView {
/// Open the room, optionally focused on one automation id.
#[must_use]
pub fn new(app: &App, focus: Option<&str>) -> Self {
pub fn new(app: &App, config: &crate::config::Config, focus: Option<&str>) -> Self {
let mut view = Self {
rows: Vec::new(),
row: 0,
@@ -93,6 +104,12 @@ impl AutomationsView {
last_refresh_at: Instant::now(),
problem: None,
list_body: Cell::new(Rect::ZERO),
config: config.clone(),
workspace: app.workspace.clone(),
editor: None,
notice: None,
new_button: Cell::new(Rect::ZERO),
edit_button: Cell::new(Rect::ZERO),
};
view.refresh();
if let Some(focus) = focus
@@ -116,9 +133,84 @@ impl AutomationsView {
last_refresh_at: Instant::now(),
problem: None,
list_body: Cell::new(Rect::ZERO),
config: crate::config::Config::default(),
workspace: std::env::temp_dir(),
editor: None,
notice: None,
new_button: Cell::new(Rect::ZERO),
edit_button: Cell::new(Rect::ZERO),
}
}
fn open_editor(&mut self, edit: bool) {
let original = if edit {
let Some(row) = self.selected() else {
return;
};
Some(row.record.clone())
} else {
None
};
self.notice = None;
self.editor = Some(AutomationEditor::new(
&self.config,
&self.workspace,
self.locale,
original,
));
}
fn editor_action(&mut self, action: EditorAction) -> ViewAction {
match action {
EditorAction::Cancel => self.editor = None,
EditorAction::Save => {
let result = self
.manager
.as_ref()
.ok_or_else(|| {
tr(self.locale, MessageId::AutomationManagerUnavailable).into_owned()
})
.and_then(|manager| {
manager.try_lock().map_err(|_| {
tr(self.locale, MessageId::AutomationEditorBusy).into_owned()
})
})
.and_then(|manager| {
self.editor
.as_ref()
.unwrap()
.save(&manager)
.map_err(|error| error.to_string())
});
match result {
Ok(record) => {
self.editor = None;
self.refresh();
if let Some(index) =
self.rows.iter().position(|row| row.record.id == record.id)
{
self.row = index;
}
self.detail_open = true;
self.detail_scroll = 0;
self.notice = Some(
tr(self.locale, MessageId::AutomationEditorSaved)
.replace("{name}", &display_text(&record.name)),
);
}
Err(error) => {
self.editor.as_mut().unwrap().problem = Some(
tr(self.locale, MessageId::AutomationEditorSaveFailed)
.replace("{error}", &error),
)
}
}
}
EditorAction::None => {}
}
ViewAction::None
}
/// Re-read definitions and recent runs, keeping the selected id when the
/// list reorders. A busy manager keeps the previous snapshot.
fn refresh(&mut self) {
@@ -291,7 +383,7 @@ impl AutomationsView {
format!(" {}", tr(self.locale, MessageId::AutomationScopeNote)),
Style::default().fg(palette::TEXT_DIM),
)),
Line::from(""),
Line::from(self.notice.clone().unwrap_or_default()),
]
}
@@ -421,8 +513,17 @@ impl AutomationsView {
}
if let Some(model) = record.model.as_deref() {
lines.push(Line::from(vec![
label(MessageId::AutomationModeLabel),
value(model.to_string()),
label(MessageId::SetupCardModelLabel),
value(
record
.model_provider_id
.as_ref()
.or(record.model_provider.as_ref())
.map_or_else(
|| model.to_string(),
|provider| format!("{provider} / {model}"),
),
),
]));
}
lines.push(Line::from(""));
@@ -530,7 +631,19 @@ impl ModalView for AutomationsView {
}
fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
if let Some(editor) = self.editor.as_mut() {
let action = editor.key(key);
return self.editor_action(action);
}
match key.code {
KeyCode::Char('n') => {
self.open_editor(false);
ViewAction::None
}
KeyCode::Char('e') => {
self.open_editor(true);
ViewAction::None
}
KeyCode::Esc => {
if self.detail_open {
self.detail_open = false;
@@ -584,6 +697,21 @@ impl ModalView for AutomationsView {
}
fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
if let Some(editor) = self.editor.as_mut() {
let action = editor.mouse(mouse);
return self.editor_action(action);
}
if mouse.kind == MouseEventKind::Down(MouseButton::Left) {
let point = (mouse.column, mouse.row).into();
if self.new_button.get().contains(point) {
self.open_editor(false);
return ViewAction::None;
}
if self.edit_button.get().contains(point) {
self.open_editor(true);
return ViewAction::None;
}
}
if self.detail_open {
return ViewAction::None;
}
@@ -606,6 +734,14 @@ impl ModalView for AutomationsView {
ViewAction::None
}
fn handle_paste(&mut self, text: &str) -> bool {
if let Some(editor) = self.editor.as_mut() {
editor.paste(text);
return true;
}
false
}
fn tick(&mut self) -> ViewAction {
let interval = if self.rows.iter().any(|row| row.live_run().is_some()) {
Duration::from_millis(500)
@@ -623,15 +759,45 @@ impl ModalView for AutomationsView {
Block::default()
.style(Style::default().bg(palette::WHALE_BG))
.render(area, buf);
if let Some(editor) = &self.editor {
editor.render(area, buf);
return;
}
let hints = self.footer_hints();
let content = render_modal_footer(area, buf, &hints);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(1)])
.constraints([Constraint::Length(4), Constraint::Min(1)])
.split(content);
Paragraph::new(self.header_lines())
.wrap(Wrap { trim: false })
.render(chunks[0], buf);
// Each header fact owns one row. Wrapping the counts/scope at compact
// widths must not push the explicit Save receipt out of the header.
Paragraph::new(self.header_lines()).render(chunks[0], buf);
let mut x = chunks[0].x;
self.new_button.set(Rect::ZERO);
self.edit_button.set(Rect::ZERO);
for (key, label, hit) in [
("n", MessageId::AutomationEditorNew, &self.new_button),
("e", MessageId::AutomationEditorEdit, &self.edit_button),
] {
if key == "e" && self.selected().is_none() {
continue;
}
let text = format!("[{key} {}] ", tr(self.locale, label));
let width = u16::try_from(crate::tui::ui_text::text_display_width(&text))
.unwrap_or(u16::MAX)
.min(chunks[0].right().saturating_sub(x));
let rect = Rect::new(
x,
chunks[0].y + chunks[0].height.saturating_sub(1),
width,
1,
);
Paragraph::new(text)
.style(Style::default().fg(palette::WHALE_ACTION))
.render(rect, buf);
hit.set(rect);
x += width;
}
if self.detail_open {
self.render_detail(chunks[1], buf);
} else {
@@ -656,6 +822,8 @@ mod tests {
rrule: "FREQ=DAILY;BYHOUR=17".to_string(),
cwds: vec![std::path::PathBuf::from("/tmp/cwc")],
model: None,
model_provider: None,
model_provider_id: None,
mode: None,
allow_shell: None,
trust_mode: None,
@@ -765,6 +933,75 @@ mod tests {
));
}
#[test]
fn editor_save_and_cancel_require_explicit_room_actions() {
let _env = crate::test_support::lock_test_env();
let root = tempfile::tempdir().unwrap();
let manager = std::sync::Arc::new(tokio::sync::Mutex::new(
crate::automation_manager::AutomationManager::open(root.path().join("store")).unwrap(),
));
let mut view = AutomationsView::from_rows(Vec::new(), Locale::En);
view.workspace = root.path().to_path_buf();
view.manager = Some(manager.clone());
view.handle_key(key(KeyCode::Char('n')));
assert!(view.handle_paste("draft"));
view.handle_key(key(KeyCode::Tab));
view.handle_paste("line one\nline two");
assert!(
manager
.try_lock()
.unwrap()
.list_automations()
.unwrap()
.is_empty()
);
let area = Rect::new(0, 0, 40, 12);
let mut buffer = Buffer::empty(area);
view.render(area, &mut buffer);
let cancel = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 17,
row: 11,
modifiers: KeyModifiers::NONE,
};
view.handle_mouse(cancel);
assert!(view.editor.is_none());
assert!(
manager
.try_lock()
.unwrap()
.list_automations()
.unwrap()
.is_empty()
);
view.handle_key(key(KeyCode::Char('n')));
view.handle_paste("saved");
view.handle_key(key(KeyCode::Tab));
view.handle_paste("prompt");
view.render(area, &mut buffer);
view.handle_mouse(MouseEvent {
column: 2,
..cancel
});
assert!(view.editor.is_none());
assert_eq!(
manager
.try_lock()
.unwrap()
.list_automations()
.unwrap()
.len(),
1
);
view.render(area, &mut buffer);
let receipt: String = (0..40).map(|x| buffer[(x, 2)].symbol()).collect();
assert!(
receipt.contains("Saved saved"),
"compact receipt: {receipt}"
);
}
#[test]
fn renders_state_next_run_and_the_live_cancel_hint() {
let view = view();
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,230 @@
//! Real terminal acceptance: draft, schedule/model mouse controls, save,
//! cancel, edit, and process restart. All saved jobs are paused; provider
//! fixtures point to an unused loopback port and no inference is requested.
#![cfg(all(unix, feature = "long-running-tests"))]
use std::path::{Path, PathBuf};
use std::time::Duration;
use super::qa_harness;
use qa_harness::harness::{Harness, SealedWorkspace, make_sealed_workspace};
use qa_harness::keys::{key, mouse};
const WAIT: Duration = Duration::from_secs(15);
const SIZES: [(u16, u16); 5] = [(12, 40), (16, 60), (24, 80), (32, 100), (40, 140)];
fn wait(tui: &mut Harness, text: &str) {
if tui.wait_for_text(text, WAIT).is_err() {
panic!("missing {text:?}\n{}", tui.diagnostics());
}
}
fn settle(tui: &mut Harness) {
tui.wait_for_idle(Duration::from_millis(180), Duration::from_secs(5))
.unwrap();
}
fn send(tui: &mut Harness, bytes: impl AsRef<[u8]>) {
tui.send(bytes).unwrap();
settle(tui);
}
fn paste(tui: &mut Harness, text: &str) {
tui.paste(text).unwrap();
settle(tui);
}
fn click(tui: &mut Harness, text: &str) {
wait(tui, text);
settle(tui);
tui.pump();
let (row, col) = tui.frame().find_text(text).unwrap();
send(tui, mouse::click(row, col));
}
fn open(workspace: &SealedWorkspace, store: &Path, rows: u16, cols: u16) -> Harness {
let mut tui = Harness::builder(Harness::cargo_bin("codewhale-tui"))
.cwd(workspace.workspace())
.clear_env()
.seal_home(workspace.home())
.env("CODEWHALE_AUTOMATIONS_DIR", store.to_str().unwrap())
.env("CODEWHALE_DISABLE_MODELS_DEV_FETCH", "1")
.env("CODEWHALE_NO_UPDATE_CHECK", "1")
.env("CODEWHALE_TELEMETRY", "0")
.env("DO_NOT_TRACK", "1")
.env("NO_ANIMATIONS", "1")
.env("RUST_LOG", "warn")
.args([
"--workspace",
workspace.workspace().to_str().unwrap(),
"--no-project-config",
"--fresh",
"--mouse-capture",
])
.size(rows, cols)
.spawn()
.unwrap();
wait(&mut tui, "New session");
send(&mut tui, "/automation");
send(&mut tui, key::enter());
wait(&mut tui, "Scheduled automations");
tui
}
fn record(store: &Path) -> (PathBuf, serde_json::Value) {
let files: Vec<_> = std::fs::read_dir(store.join("automations"))
.unwrap()
.map(|entry| entry.unwrap().path())
.filter(|path| path.extension().is_some_and(|ext| ext == "json"))
.collect();
assert_eq!(
files.len(),
1,
"only an explicit Save may create a definition"
);
(
files[0].clone(),
serde_json::from_slice(&std::fs::read(&files[0]).unwrap()).unwrap(),
)
}
#[test]
fn automations_editor_create_edit_cancel_and_reopen_across_terminal_sizes() {
for (rows, cols) in SIZES {
let workspace = make_sealed_workspace().unwrap();
let store = workspace.home().join("qa-automations");
let second_workspace = workspace.workspace().join("second-workspace");
std::fs::create_dir_all(&second_workspace).unwrap();
std::fs::write(workspace.home().join(".codewhale/.onboarded"), "").unwrap();
let trust = workspace.workspace().join(".deepseek");
std::fs::create_dir_all(&trust).unwrap();
std::fs::write(trust.join("trusted"), "").unwrap();
std::fs::write(
workspace.home().join(".codewhale/config.toml"),
r#"
provider = "first"
[providers.first]
kind = "openai-compatible"
base_url = "http://127.0.0.1:9/first/v1"
api_key = "fixture-first"
model = "private-first"
[providers.second]
kind = "openai-compatible"
base_url = "http://127.0.0.1:9/second/v1"
api_key = "fixture-second"
model = "private-second"
[notifications]
method = "off"
completion_sound = "off"
"#,
)
.unwrap();
let mut tui = open(&workspace, &store, rows, cols);
click(&mut tui, "n New");
wait(&mut tui, "Time zone: local");
let name = format!("qa-auto-{cols}");
paste(&mut tui, &name);
send(&mut tui, key::tab());
paste(&mut tui, "first line\nsecond line 🐳");
send(&mut tui, key::tab());
send(&mut tui, key::right()); // Weekly
send(&mut tui, key::tab());
send(&mut tui, key::ctrl('a'));
paste(&mut tui, "17:45");
send(&mut tui, key::up());
click(&mut tui, "[+]");
wait(&mut tui, "18:15");
send(&mut tui, key::tab());
send(&mut tui, key::right());
send(&mut tui, " ");
click(&mut tui, "Wed");
send(&mut tui, key::tab());
send(&mut tui, key::enter());
paste(&mut tui, "private-second");
wait(&mut tui, "second / private-second");
send(&mut tui, key::enter());
send(&mut tui, key::tab());
send(&mut tui, key::ctrl('a'));
paste(&mut tui, second_workspace.to_str().unwrap());
send(&mut tui, key::tab());
send(&mut tui, " ");
wait(&mut tui, "Paused");
send(&mut tui, key::ctrl_s());
wait(&mut tui, &format!("Saved {name}"));
let (path, saved) = record(&store);
assert_eq!(saved["name"], name);
assert_eq!(saved["prompt"], "first line\nsecond line 🐳");
assert_eq!(
saved["rrule"],
"FREQ=WEEKLY;BYDAY=MO,TU,WE;BYHOUR=18;BYMINUTE=15"
);
assert_eq!(saved["status"], "paused");
assert!(saved["next_run_at"].is_null());
assert_eq!(
saved["cwds"][0],
second_workspace.canonicalize().unwrap().to_str().unwrap()
);
assert_eq!(saved["model"], "private-second");
assert_eq!(saved["model_provider"], "custom");
assert_eq!(saved["model_provider_id"], "second");
assert!(
saved["allow_shell"].is_null()
&& saved["trust_mode"].is_null()
&& saved["auto_approve"].is_null()
);
let before_cancel = std::fs::read(&path).unwrap();
send(&mut tui, "e");
wait(&mut tui, "Time zone: local");
send(&mut tui, key::ctrl('a'));
paste(&mut tui, "discard this edit");
send(&mut tui, key::esc());
assert_eq!(std::fs::read(&path).unwrap(), before_cancel);
send(&mut tui, "e");
wait(&mut tui, "Time zone: local");
send(&mut tui, key::ctrl('a'));
paste(&mut tui, &format!("{name}-edited"));
send(&mut tui, key::tab());
send(&mut tui, key::ctrl('a'));
paste(&mut tui, "updated prompt\nkeep this newline");
send(&mut tui, key::tab());
send(&mut tui, key::left()); // Daily
send(&mut tui, key::tab());
send(&mut tui, key::ctrl('a'));
paste(&mut tui, "08:30");
click(&mut tui, "[save");
wait(&mut tui, &format!("Saved {name}-edited"));
let (_, edited) = record(&store);
assert_eq!(edited["name"], format!("{name}-edited"));
assert_eq!(edited["prompt"], "updated prompt\nkeep this newline");
assert_eq!(
edited["rrule"],
"FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU;BYHOUR=8;BYMINUTE=30"
);
assert_eq!(edited["model_provider_id"], "second");
assert_eq!(edited["model"], "private-second");
assert_eq!(edited["status"], "paused");
drop(tui);
let mut reopened = open(&workspace, &store, rows, cols);
send(&mut reopened, "e");
wait(&mut reopened, &format!("{name}-edited"));
send(&mut reopened, key::tab());
wait(&mut reopened, "updated prompt");
for _ in 0..3 {
send(&mut reopened, key::tab());
}
wait(&mut reopened, "second / private-second");
std::fs::write(
format!("/tmp/cw-automation-editor-pty-{cols}x{rows}.txt"),
reopened.frame().text(),
)
.unwrap();
send(&mut reopened, key::esc());
assert_eq!(record(&store).1, edited);
println!(
"PASS {cols}x{rows}: create, multiline paste, time/day/model mouse+keyboard, explicit save, cancel unchanged, edit, paused persistence, process restart"
);
}
}
+2
View File
@@ -12,6 +12,8 @@ mod qa_harness;
#[cfg(all(unix, feature = "long-running-tests"))]
mod active_composer_pointer_pty;
#[cfg(all(unix, feature = "long-running-tests"))]
mod automations_editor_pty;
mod core_session_command_extraction;
mod directory_listing_acceptance;
mod epic_acceptance_harness;
+22
View File
@@ -2482,6 +2482,28 @@ iTerm2, WezTerm, Ghostty, and kitty are matched first and use their own
notification protocols, and `method = "osc9"` / `"bel"` / `"off"` opt out
of the `osascript` path explicitly.
## Automations in the terminal
Open `/automation`, then choose **New automation** (`n`) or **Edit automation**
(`e`). The form edits the name, multiline prompt, schedule, model, workspace,
and enabled status. `Tab` moves between fields; `Enter` inserts a newline in
the prompt. Schedule presets include daily, weekly, hourly, once, and a custom
RRULE. Time fields accept `HH:MM`; their arrow controls change the time by
15 minutes. Weekly day buttons and model search support mouse and keyboard.
The next-run preview uses the scheduler's local time zone, shown with its UTC
offset. An enabled automation can run after **Save** (`Ctrl+S`); a paused one
has no scheduled next run. **Cancel** (`Esc`) discards the draft. Editing keeps
existing permission settings, custom schedules, and additional workspace
entries unless the corresponding supported field is explicitly changed.
Choosing a concrete model pins both the model and its exact configured
provider, including named custom routes. Later changes to the active provider
do not move that automation's pin. The default-model choice and legacy
definitions without a provider pin keep the runtime's existing default
behavior. New automation records use schema v2 and tasks use v3 so older
runtimes reject records whose provider pins they cannot preserve.
## Lifecycle Outbox (`[lifecycle_outbox]`)
The lifecycle outbox is an opt-in, machine-readable stream of session,