deepvista-cli 0.4.0__tar.gz → 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- deepvista_cli-0.5.0/.release-please-manifest.json +3 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/CHANGELOG.md +7 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/PKG-INFO +1 -1
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/deepvista_cli/commands/skill.py +108 -1
- deepvista_cli-0.5.0/deepvista_cli/commands/task_queue.py +524 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/deepvista_cli/main.py +3 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/pyproject.toml +1 -1
- deepvista_cli-0.5.0/tests/test_task_queue_commands.py +519 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/uv.lock +1 -1
- deepvista_cli-0.4.0/.release-please-manifest.json +0 -3
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/.claude-plugin/marketplace.json +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/.github/workflows/ci.yml +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/.github/workflows/publish.yml +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/.github/workflows/release-please.yml +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/.gitignore +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/.pre-commit-config.yaml +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/CLAUDE.md +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/CONTRIBUTING.md +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/LICENSE +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/README.md +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/deepvista_cli/__init__.py +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/deepvista_cli/agent_catalog.py +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/deepvista_cli/auth/__init__.py +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/deepvista_cli/auth/callback_server.py +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/deepvista_cli/auth/login.py +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/deepvista_cli/auth/tokens.py +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/deepvista_cli/client/__init__.py +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/deepvista_cli/client/http.py +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/deepvista_cli/client/origin.py +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/deepvista_cli/commands/__init__.py +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/deepvista_cli/commands/agents.py +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/deepvista_cli/commands/auth.py +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/deepvista_cli/commands/card.py +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/deepvista_cli/commands/chat.py +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/deepvista_cli/commands/config.py +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/deepvista_cli/commands/lint.py +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/deepvista_cli/commands/memory.py +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/deepvista_cli/commands/notes.py +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/deepvista_cli/commands/schedule.py +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/deepvista_cli/commands/session.py +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/deepvista_cli/commands/upgrade.py +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/deepvista_cli/commands/vistabase.py +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/deepvista_cli/config.py +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/deepvista_cli/output/__init__.py +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/deepvista_cli/output/formatter.py +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/deepvista_cli/resources/__init__.py +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/deepvista_cli/resources/workflow_host_runtime.md +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/deepvista_cli/session_note.py +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/deepvista_cli/skill_catalog.py +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/deepvista_cli/tui/__init__.py +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/deepvista_cli/tui/app.py +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/deepvista_cli/workflow_doc.py +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/docs/assets/deepvista-banner.png +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/install.ps1 +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/install.sh +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/plugins/README.md +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/plugins/claude-code/README.md +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/plugins/claude-code/agents/.gitignore +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/plugins/claude-code/commands/deepvista.md +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/plugins/claude-code/commands/refresh-skills.md +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/plugins/claude-code/hooks/hooks.json +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/plugins/claude-code/scripts/deepvista-session-end.sh +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/plugins/claude-code/scripts/deepvista-session-start.sh +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/plugins/claude-code/scripts/deepvista-session-turn.sh +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/plugins/claude-code/scripts/deepvista-skill-url.py +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/plugins/claude-code/scripts/deepvista-sync.sh +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/plugins/claude-code/skills/.gitignore +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/plugins/claude-code/skills/install-deepvista-cli/SKILL.md +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/release-please-config.json +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/scripts/check_plugin_version.py +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/skills/deepvista/SKILL.md +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/skills/deepvista/reference/chat.md +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/skills/deepvista/reference/lint.md +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/skills/deepvista/reference/memory.md +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/skills/deepvista/reference/notes.md +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/skills/deepvista/reference/openclaw.md +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/skills/deepvista/reference/session.md +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/skills/deepvista/reference/shared.md +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/skills/deepvista/reference/skill-analyze-notes.md +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/skills/deepvista/reference/skill-create-from-note.md +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/skills/deepvista/reference/skill-import-files.md +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/skills/deepvista/reference/skill-research-to-skill.md +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/skills/deepvista/reference/skill.md +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/skills/deepvista/reference/vistabase-card.md +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/skills/deepvista/reference/vistabase.md +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/skills/dv-workflow/SKILL.md +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/tests/__init__.py +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/tests/test_agent_id_tagging.py +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/tests/test_session_note_format.py +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/tests/test_skill_catalog.py +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/tests/test_skill_commands.py +0 -0
- {deepvista_cli-0.4.0 → deepvista_cli-0.5.0}/uninstall.sh +0 -0
|
@@ -37,6 +37,13 @@ users what's new between the version they have installed and the latest release.
|
|
|
37
37
|
adopts a pre-existing server-side row instead of failing when the local
|
|
38
38
|
file is missing.
|
|
39
39
|
|
|
40
|
+
## [0.5.0](https://github.com/DeepVista-AI/deepvista-cli/compare/v0.4.0...v0.5.0) (2026-06-04)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
### Features
|
|
44
|
+
|
|
45
|
+
* **DV-936:** task_queue run/list/setup commands ([#155](https://github.com/DeepVista-AI/deepvista-cli/issues/155)) ([0d90691](https://github.com/DeepVista-AI/deepvista-cli/commit/0d9069106d14068d657e16415be80dec95f12085))
|
|
46
|
+
|
|
40
47
|
## [0.4.0](https://github.com/DeepVista-AI/deepvista-cli/compare/v0.3.0...v0.4.0) (2026-06-03)
|
|
41
48
|
|
|
42
49
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: deepvista-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: CLI for DeepVista — chat, notes, skills, and memory from your terminal.
|
|
5
5
|
Project-URL: Homepage, https://deepvista.ai
|
|
6
6
|
Project-URL: Repository, https://github.com/DeepVista-AI/deepvista-cli
|
|
@@ -133,9 +133,37 @@ def skill_get(ctx: click.Context, skill_id: str) -> None:
|
|
|
133
133
|
"tool_plan."
|
|
134
134
|
),
|
|
135
135
|
)
|
|
136
|
+
@click.option(
|
|
137
|
+
"--webhook",
|
|
138
|
+
is_flag=True,
|
|
139
|
+
default=False,
|
|
140
|
+
help=(
|
|
141
|
+
"Mark this as a webhook-queued run (DV-955). Appends the task-queue "
|
|
142
|
+
"completion contract so the host agent reports the queue task after "
|
|
143
|
+
"`skill complete`. Set automatically on commands the webhook enqueues."
|
|
144
|
+
),
|
|
145
|
+
)
|
|
146
|
+
@click.option(
|
|
147
|
+
"--best-effort",
|
|
148
|
+
is_flag=True,
|
|
149
|
+
default=False,
|
|
150
|
+
help=(
|
|
151
|
+
"Unattended run: instruct the host agent to answer open questions "
|
|
152
|
+
"from the vistabase instead of stalling, note assumptions, and only "
|
|
153
|
+
"pause on hard blockers (DV-955)."
|
|
154
|
+
),
|
|
155
|
+
)
|
|
136
156
|
@click.option("--dry-run", is_flag=True, default=False, help="Preview what would happen without making any changes.")
|
|
137
157
|
@click.pass_context
|
|
138
|
-
def skill_run(
|
|
158
|
+
def skill_run(
|
|
159
|
+
ctx: click.Context,
|
|
160
|
+
skill_id: str,
|
|
161
|
+
user_input: str | None,
|
|
162
|
+
mode: str,
|
|
163
|
+
webhook: bool,
|
|
164
|
+
best_effort: bool,
|
|
165
|
+
dry_run: bool,
|
|
166
|
+
) -> None:
|
|
139
167
|
"""Run a Skill — host mode by default; ``--mode deepvista`` delegates the whole run server-side.
|
|
140
168
|
|
|
141
169
|
> [!CAUTION] This is a write command — host mode acquires the parent
|
|
@@ -160,6 +188,36 @@ def skill_run(ctx: click.Context, skill_id: str, user_input: str | None, mode: s
|
|
|
160
188
|
_skill_run_deepvista(ctx, skill_id, user_input, dry_run=dry_run)
|
|
161
189
|
return
|
|
162
190
|
|
|
191
|
+
emit_host_run_packet(
|
|
192
|
+
ctx,
|
|
193
|
+
skill_id,
|
|
194
|
+
user_input,
|
|
195
|
+
mode,
|
|
196
|
+
dry_run=dry_run,
|
|
197
|
+
webhook=webhook,
|
|
198
|
+
best_effort=best_effort,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def emit_host_run_packet(
|
|
203
|
+
ctx: click.Context,
|
|
204
|
+
skill_id: str,
|
|
205
|
+
user_input: str | None,
|
|
206
|
+
mode: str = "host",
|
|
207
|
+
*,
|
|
208
|
+
dry_run: bool = False,
|
|
209
|
+
webhook: bool = False,
|
|
210
|
+
best_effort: bool = False,
|
|
211
|
+
task_id: str | None = None,
|
|
212
|
+
) -> None:
|
|
213
|
+
"""Fetch the skill, acquire the run lock, and print the host run packet.
|
|
214
|
+
|
|
215
|
+
Shared by ``skill run`` (host / auto modes) and ``task_queue run --host``
|
|
216
|
+
(DV-955), which emits packets for webhook-queued workflow tasks instead
|
|
217
|
+
of subprocess-executing them — a queued workflow needs the surrounding
|
|
218
|
+
host agent to drive it. ``task_id`` (only known on the task-queue path)
|
|
219
|
+
threads the queue entry into the completion contract.
|
|
220
|
+
"""
|
|
163
221
|
# host / auto: fetch the card, optionally acquire the lock, and emit a
|
|
164
222
|
# run packet the host agent drives.
|
|
165
223
|
card = _client(ctx).post("/get_context_card", {"card_id": skill_id, "card_type": "skill"})
|
|
@@ -196,7 +254,11 @@ def skill_run(ctx: click.Context, skill_id: str, user_input: str | None, mode: s
|
|
|
196
254
|
"phase_routes": phase_routes,
|
|
197
255
|
"user_input": user_input or "",
|
|
198
256
|
"skill_status": card.get("status", ""),
|
|
257
|
+
"webhook": webhook,
|
|
258
|
+
"best_effort": best_effort,
|
|
199
259
|
}
|
|
260
|
+
if task_id:
|
|
261
|
+
run_header["task_id"] = task_id
|
|
200
262
|
|
|
201
263
|
if dry_run:
|
|
202
264
|
format_output(
|
|
@@ -222,6 +284,12 @@ def skill_run(ctx: click.Context, skill_id: str, user_input: str | None, mode: s
|
|
|
222
284
|
click.echo("---")
|
|
223
285
|
click.echo()
|
|
224
286
|
click.echo(_load_host_runtime_contract())
|
|
287
|
+
if best_effort:
|
|
288
|
+
click.echo()
|
|
289
|
+
click.echo(_BEST_EFFORT_STANZA)
|
|
290
|
+
if webhook:
|
|
291
|
+
click.echo()
|
|
292
|
+
click.echo(_webhook_task_stanza(task_id))
|
|
225
293
|
|
|
226
294
|
|
|
227
295
|
def _skill_run_deepvista(
|
|
@@ -258,6 +326,45 @@ def _load_host_runtime_contract() -> str:
|
|
|
258
326
|
return resources.files("deepvista_cli.resources").joinpath("workflow_host_runtime.md").read_text(encoding="utf-8")
|
|
259
327
|
|
|
260
328
|
|
|
329
|
+
# Appended to the runtime contract for unattended runs (DV-955). The run was
|
|
330
|
+
# triggered by a webhook — there is no human in the loop to answer questions.
|
|
331
|
+
_BEST_EFFORT_STANZA = """\
|
|
332
|
+
## Best-effort mode (unattended run)
|
|
333
|
+
|
|
334
|
+
This run was triggered without a human in the loop. Do NOT stall waiting
|
|
335
|
+
for answers:
|
|
336
|
+
|
|
337
|
+
- When a step needs information, search the vistabase first:
|
|
338
|
+
`deepvista card +search "…"`, `deepvista vistabase +search "…"`,
|
|
339
|
+
`deepvista notes list`. Prefer an answer found there over asking.
|
|
340
|
+
- When nothing answers, make the most reasonable assumption, state it in
|
|
341
|
+
the phase's artifact note, and move to the next step.
|
|
342
|
+
- Reserve `deepvista skill phase pause` for hard blockers only (missing
|
|
343
|
+
credentials, unavailable tools) — never for open questions.
|
|
344
|
+
- Anything that would normally be sent externally (emails, invites) must
|
|
345
|
+
be left as a DRAFT for human review, never dispatched."""
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _webhook_task_stanza(task_id: str | None) -> str:
|
|
349
|
+
"""Completion contract for webhook-queued runs (DV-955).
|
|
350
|
+
|
|
351
|
+
The queue entry stays ``running`` until the host agent reports it —
|
|
352
|
+
nothing else will, so skipping this leaves a permanently stuck task.
|
|
353
|
+
"""
|
|
354
|
+
task_ref = task_id or "<task_id from `deepvista task_queue list`>"
|
|
355
|
+
return f"""\
|
|
356
|
+
## Webhook task completion
|
|
357
|
+
|
|
358
|
+
This run came off the agent task queue. The queue entry stays `running`
|
|
359
|
+
until YOU report it — after `deepvista skill complete` (or on failure):
|
|
360
|
+
|
|
361
|
+
```
|
|
362
|
+
deepvista task_queue complete {task_ref} --status completed
|
|
363
|
+
# or, when the run could not finish:
|
|
364
|
+
deepvista task_queue complete {task_ref} --status failed --note "<one short sentence>"
|
|
365
|
+
```"""
|
|
366
|
+
|
|
367
|
+
|
|
261
368
|
# ---------------------------------------------------------------------------
|
|
262
369
|
# Phase mutators — used by host agents driving the workflow themselves
|
|
263
370
|
# ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
"""deepvista task_queue — pull-based execution of queued CLI commands (DV-936).
|
|
2
|
+
|
|
3
|
+
The web app enqueues DeepVista CLI commands onto a managed agent's
|
|
4
|
+
`task_queue`; this command group lets the agent's machine poll and run them:
|
|
5
|
+
|
|
6
|
+
deepvista task_queue run — claim pending tasks and execute them
|
|
7
|
+
deepvista task_queue list — show this machine's queue
|
|
8
|
+
deepvista task_queue complete — report a workflow task's outcome (host agent)
|
|
9
|
+
deepvista task_queue setup — install a crontab entry that polls periodically
|
|
10
|
+
|
|
11
|
+
Workflow tasks (DV-955): webhook-queued `deepvista skill run` entries can't
|
|
12
|
+
be subprocess-executed — a workflow needs the surrounding host agent (Claude
|
|
13
|
+
Code etc.) to drive its phases. `task_queue run --host` claims them and
|
|
14
|
+
emits their run packets to stdout for the host agent; headless runs (cron)
|
|
15
|
+
claim command-only so workflow tasks stay pending until a host run. The
|
|
16
|
+
host agent reports the outcome via `task_queue complete` after
|
|
17
|
+
`skill complete`.
|
|
18
|
+
|
|
19
|
+
Safety: only commands whose first token is `deepvista` are executed
|
|
20
|
+
(shlex-parsed, shell=False). The backend enforces the same allowlist at
|
|
21
|
+
enqueue time; the check here guards against tampered queue rows.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json as _json
|
|
27
|
+
import shlex
|
|
28
|
+
import shutil
|
|
29
|
+
import subprocess
|
|
30
|
+
import sys
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
import click
|
|
34
|
+
|
|
35
|
+
from deepvista_cli.client.http import DeepVistaClient
|
|
36
|
+
from deepvista_cli.client.origin import detect_agent_tool
|
|
37
|
+
from deepvista_cli.commands.agents import AGENTS_DIR, _load_agent_id
|
|
38
|
+
from deepvista_cli.commands.skill import emit_host_run_packet
|
|
39
|
+
from deepvista_cli.config import CONFIG_DIR
|
|
40
|
+
from deepvista_cli.output.formatter import format_output, output_error
|
|
41
|
+
|
|
42
|
+
TASK_COLUMNS = ["id", "status", "command", "created_at", "finished_at", "exit_code"]
|
|
43
|
+
|
|
44
|
+
# Only the DeepVista CLI itself may be invoked from the queue.
|
|
45
|
+
ALLOWED_COMMAND_BINARY = "deepvista"
|
|
46
|
+
|
|
47
|
+
# Per-task execution budget; a hung task must not wedge the cron tick forever.
|
|
48
|
+
TASK_TIMEOUT_SECONDS = 600
|
|
49
|
+
|
|
50
|
+
# Reported output is truncated to a tail (mirrors the backend cap).
|
|
51
|
+
OUTPUT_TAIL_MAX_CHARS = 2000
|
|
52
|
+
|
|
53
|
+
# Marker comment identifying crontab entries owned by `task_queue setup`.
|
|
54
|
+
CRON_MARKER = "# deepvista-task-queue"
|
|
55
|
+
|
|
56
|
+
CRON_LOG_PATH = CONFIG_DIR / "task_queue.log"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Helpers
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _client(ctx: click.Context) -> DeepVistaClient:
|
|
65
|
+
return ctx.obj._client
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _output(ctx: click.Context, data: object, **kwargs: object) -> None:
|
|
69
|
+
format_output(data, ctx.obj.output_format, **kwargs) # type: ignore[arg-type]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _resolve_machine_agent_id(agent_type: str | None, agent_role: str | None) -> str | None:
|
|
73
|
+
"""Find the registered agent this machine's queue belongs to.
|
|
74
|
+
|
|
75
|
+
Resolution order: explicit --type/--role, then the detected host tool,
|
|
76
|
+
then the most recently registered agent of any type on this machine.
|
|
77
|
+
"""
|
|
78
|
+
if agent_type:
|
|
79
|
+
return _load_agent_id(agent_type, agent_role)
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
detected, _ = detect_agent_tool()
|
|
83
|
+
except Exception:
|
|
84
|
+
detected = None
|
|
85
|
+
if detected:
|
|
86
|
+
agent_id = _load_agent_id(detected, agent_role)
|
|
87
|
+
if agent_id:
|
|
88
|
+
return agent_id
|
|
89
|
+
|
|
90
|
+
# Cron runs outside any agent host — fall back to the newest registration.
|
|
91
|
+
candidates: list[tuple[float, Path]] = []
|
|
92
|
+
if AGENTS_DIR.exists():
|
|
93
|
+
for path in AGENTS_DIR.glob("*.json"):
|
|
94
|
+
try:
|
|
95
|
+
candidates.append((path.stat().st_mtime, path))
|
|
96
|
+
except OSError:
|
|
97
|
+
continue
|
|
98
|
+
candidates.sort(reverse=True)
|
|
99
|
+
for _, path in candidates:
|
|
100
|
+
try:
|
|
101
|
+
agent_id = _json.loads(path.read_text()).get("agent_id")
|
|
102
|
+
except (OSError, _json.JSONDecodeError):
|
|
103
|
+
continue
|
|
104
|
+
if agent_id:
|
|
105
|
+
return agent_id
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _require_machine_agent_id(agent_type: str | None, agent_role: str | None) -> str:
|
|
110
|
+
agent_id = _resolve_machine_agent_id(agent_type, agent_role)
|
|
111
|
+
if not agent_id:
|
|
112
|
+
output_error(3, "No registered agent on this machine", "Run 'deepvista agents register' first.")
|
|
113
|
+
raise SystemExit(3)
|
|
114
|
+
return agent_id
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _deepvista_binary() -> str:
|
|
118
|
+
"""Absolute path to the `deepvista` entry point (cron has a minimal PATH)."""
|
|
119
|
+
binary = shutil.which(ALLOWED_COMMAND_BINARY)
|
|
120
|
+
if binary:
|
|
121
|
+
return binary
|
|
122
|
+
if sys.argv and Path(sys.argv[0]).name == ALLOWED_COMMAND_BINARY:
|
|
123
|
+
return str(Path(sys.argv[0]).resolve())
|
|
124
|
+
return ALLOWED_COMMAND_BINARY
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _validate_command(command: str) -> str | None:
|
|
128
|
+
"""Return an error message when `command` is not an allowed CLI invocation."""
|
|
129
|
+
try:
|
|
130
|
+
tokens = shlex.split(command)
|
|
131
|
+
except ValueError as exc:
|
|
132
|
+
return f"Command is not shell-parseable: {exc}"
|
|
133
|
+
if not tokens:
|
|
134
|
+
return "Command is empty"
|
|
135
|
+
if tokens[0] != ALLOWED_COMMAND_BINARY:
|
|
136
|
+
return f"Only '{ALLOWED_COMMAND_BINARY}' commands can run from the task queue"
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _is_workflow_task(task: dict) -> bool:
|
|
141
|
+
"""True when the task is a webhook-queued workflow run (DV-955).
|
|
142
|
+
|
|
143
|
+
Primary signal is the advisory ``source: "webhook"`` key the backend
|
|
144
|
+
stamps at enqueue time; the command-shape fallback covers queues
|
|
145
|
+
written before that key existed.
|
|
146
|
+
"""
|
|
147
|
+
if task.get("source") == "webhook":
|
|
148
|
+
return True
|
|
149
|
+
try:
|
|
150
|
+
tokens = shlex.split(str(task.get("command", "")))
|
|
151
|
+
except ValueError:
|
|
152
|
+
return False
|
|
153
|
+
return tokens[:3] == [ALLOWED_COMMAND_BINARY, "skill", "run"] and "--webhook" in tokens
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _parse_workflow_command(command: str) -> dict | None:
|
|
157
|
+
"""Extract skill_id / --input / --best-effort from a queued skill-run command.
|
|
158
|
+
|
|
159
|
+
The webhook composes these commands with a fixed shape
|
|
160
|
+
(``deepvista skill run --mode host <id> --input <json> --webhook
|
|
161
|
+
[--best-effort]``); parse defensively anyway since queue rows are data.
|
|
162
|
+
Returns None when the command isn't a recognizable skill run.
|
|
163
|
+
"""
|
|
164
|
+
try:
|
|
165
|
+
tokens = shlex.split(command)
|
|
166
|
+
except ValueError:
|
|
167
|
+
return None
|
|
168
|
+
if tokens[:3] != [ALLOWED_COMMAND_BINARY, "skill", "run"]:
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
value_opts = {"--mode", "--input"}
|
|
172
|
+
values: dict[str, str] = {}
|
|
173
|
+
skill_id: str | None = None
|
|
174
|
+
i = 3
|
|
175
|
+
while i < len(tokens):
|
|
176
|
+
token = tokens[i]
|
|
177
|
+
if token in value_opts:
|
|
178
|
+
if i + 1 < len(tokens):
|
|
179
|
+
values[token] = tokens[i + 1]
|
|
180
|
+
i += 2
|
|
181
|
+
elif token.startswith("--"):
|
|
182
|
+
i += 1
|
|
183
|
+
elif skill_id is None:
|
|
184
|
+
skill_id = token
|
|
185
|
+
i += 1
|
|
186
|
+
else:
|
|
187
|
+
i += 1
|
|
188
|
+
|
|
189
|
+
if not skill_id:
|
|
190
|
+
return None
|
|
191
|
+
return {
|
|
192
|
+
"skill_id": skill_id,
|
|
193
|
+
"user_input": values.get("--input"),
|
|
194
|
+
"best_effort": "--best-effort" in tokens,
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _emit_workflow_task(ctx: click.Context, agent_id: str, task: dict) -> dict:
|
|
199
|
+
"""Print a claimed workflow task's run packet for the host agent (DV-955).
|
|
200
|
+
|
|
201
|
+
The task is left ``running`` on purpose — the host agent drives the
|
|
202
|
+
workflow and reports the outcome via ``task_queue complete``. Only an
|
|
203
|
+
unparseable/unloadable task is failed here, since no agent could ever
|
|
204
|
+
pick it up.
|
|
205
|
+
"""
|
|
206
|
+
task_id = str(task.get("id", ""))
|
|
207
|
+
command = str(task.get("command", ""))
|
|
208
|
+
|
|
209
|
+
parsed = _parse_workflow_command(command)
|
|
210
|
+
if parsed is None:
|
|
211
|
+
_client(ctx).post(
|
|
212
|
+
f"/agents/{agent_id}/task-queue/{task_id}/result",
|
|
213
|
+
{"status": "failed", "exit_code": None, "output_tail": "Unparseable workflow task command"},
|
|
214
|
+
)
|
|
215
|
+
return {"task_id": task_id, "command": command, "status": "failed", "exit_code": None}
|
|
216
|
+
|
|
217
|
+
click.echo()
|
|
218
|
+
click.echo(f"=== DEEPVISTA WORKFLOW TASK {task_id} (skill {parsed['skill_id']}) ===")
|
|
219
|
+
click.echo()
|
|
220
|
+
try:
|
|
221
|
+
emit_host_run_packet(
|
|
222
|
+
ctx,
|
|
223
|
+
parsed["skill_id"],
|
|
224
|
+
parsed["user_input"],
|
|
225
|
+
"host",
|
|
226
|
+
webhook=True,
|
|
227
|
+
best_effort=parsed["best_effort"],
|
|
228
|
+
task_id=task_id,
|
|
229
|
+
)
|
|
230
|
+
except SystemExit:
|
|
231
|
+
# Skill gone / empty / phaseless — no host agent can ever run this
|
|
232
|
+
# task, so fail it instead of leaving it stuck in `running`.
|
|
233
|
+
_client(ctx).post(
|
|
234
|
+
f"/agents/{agent_id}/task-queue/{task_id}/result",
|
|
235
|
+
{"status": "failed", "exit_code": None, "output_tail": "Skill not found or not runnable"},
|
|
236
|
+
)
|
|
237
|
+
return {"task_id": task_id, "command": command, "status": "failed", "exit_code": None}
|
|
238
|
+
return {"task_id": task_id, "command": command, "status": "running", "exit_code": None}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _execute_task(ctx: click.Context, agent_id: str, task: dict) -> dict:
|
|
242
|
+
"""Run one claimed task and report its terminal result to the backend."""
|
|
243
|
+
task_id = str(task.get("id", ""))
|
|
244
|
+
command = str(task.get("command", ""))
|
|
245
|
+
|
|
246
|
+
validation_error = _validate_command(command)
|
|
247
|
+
if validation_error:
|
|
248
|
+
status, exit_code, output_tail = "failed", None, validation_error
|
|
249
|
+
else:
|
|
250
|
+
argv = shlex.split(command)
|
|
251
|
+
argv[0] = _deepvista_binary()
|
|
252
|
+
try:
|
|
253
|
+
proc = subprocess.run( # noqa: S603 — argv is allowlist-validated, shell=False
|
|
254
|
+
argv,
|
|
255
|
+
capture_output=True,
|
|
256
|
+
text=True,
|
|
257
|
+
timeout=TASK_TIMEOUT_SECONDS,
|
|
258
|
+
)
|
|
259
|
+
exit_code = proc.returncode
|
|
260
|
+
status = "completed" if exit_code == 0 else "failed"
|
|
261
|
+
output_tail = ((proc.stdout or "") + (proc.stderr or ""))[-OUTPUT_TAIL_MAX_CHARS:]
|
|
262
|
+
except subprocess.TimeoutExpired:
|
|
263
|
+
status, exit_code, output_tail = "failed", None, f"Timed out after {TASK_TIMEOUT_SECONDS}s"
|
|
264
|
+
except OSError as exc:
|
|
265
|
+
status, exit_code, output_tail = "failed", None, str(exc)
|
|
266
|
+
|
|
267
|
+
_client(ctx).post(
|
|
268
|
+
f"/agents/{agent_id}/task-queue/{task_id}/result",
|
|
269
|
+
{"status": status, "exit_code": exit_code, "output_tail": output_tail},
|
|
270
|
+
)
|
|
271
|
+
return {"task_id": task_id, "command": command, "status": status, "exit_code": exit_code}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# ---------------------------------------------------------------------------
|
|
275
|
+
# Command group
|
|
276
|
+
# ---------------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@click.group("task_queue")
|
|
280
|
+
def task_queue_group() -> None:
|
|
281
|
+
"""Run CLI commands queued for this machine's agent."""
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _detect_host_agent() -> bool:
|
|
285
|
+
"""True when an AI agent host (Claude Code, OpenClaw, …) drives this CLI."""
|
|
286
|
+
try:
|
|
287
|
+
detected, _ = detect_agent_tool()
|
|
288
|
+
except Exception:
|
|
289
|
+
return False
|
|
290
|
+
return bool(detected) and detected != "deepvista-cli"
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@task_queue_group.command("run")
|
|
294
|
+
@click.option("--type", "agent_type", default=None, help="Resolve agent by type from local storage.")
|
|
295
|
+
@click.option("--role", "agent_role", default=None, help="Resolve agent by role (with --type).")
|
|
296
|
+
@click.option(
|
|
297
|
+
"--host",
|
|
298
|
+
"host_mode",
|
|
299
|
+
is_flag=True,
|
|
300
|
+
default=False,
|
|
301
|
+
help=(
|
|
302
|
+
"Claim workflow tasks too and emit their run packets for the host "
|
|
303
|
+
"agent to drive (DV-955). Auto-enabled when an agent host is "
|
|
304
|
+
"detected; headless runs claim command tasks only."
|
|
305
|
+
),
|
|
306
|
+
)
|
|
307
|
+
@click.pass_context
|
|
308
|
+
def task_queue_run(ctx: click.Context, agent_type: str | None, agent_role: str | None, host_mode: bool) -> None:
|
|
309
|
+
"""Claim pending tasks for this machine's agent and execute them.
|
|
310
|
+
|
|
311
|
+
Returns immediately when the queue is empty, so it's cheap as a cron
|
|
312
|
+
tick. Plain command tasks run sequentially via subprocess and their
|
|
313
|
+
results are reported back. Workflow tasks (webhook-queued skill runs)
|
|
314
|
+
are only claimed in host mode: their run packets are printed for the
|
|
315
|
+
surrounding agent to drive, and the entries stay ``running`` until the
|
|
316
|
+
agent calls ``task_queue complete``.
|
|
317
|
+
"""
|
|
318
|
+
agent_id = _require_machine_agent_id(agent_type, agent_role)
|
|
319
|
+
host_mode = host_mode or _detect_host_agent()
|
|
320
|
+
|
|
321
|
+
data = _client(ctx).post(
|
|
322
|
+
f"/agents/{agent_id}/task-queue/claim",
|
|
323
|
+
None if host_mode else {"command_only": True},
|
|
324
|
+
)
|
|
325
|
+
if not data.get("success"):
|
|
326
|
+
output_error(1, "Failed to claim tasks", data.get("error", "Unknown error"))
|
|
327
|
+
raise SystemExit(1)
|
|
328
|
+
|
|
329
|
+
tasks = data.get("tasks") or []
|
|
330
|
+
if not tasks:
|
|
331
|
+
_output(ctx, {"agent_id": agent_id, "tasks_run": 0}, title="Task Queue")
|
|
332
|
+
return
|
|
333
|
+
|
|
334
|
+
command_tasks = [t for t in tasks if not _is_workflow_task(t)]
|
|
335
|
+
# Workflow tasks only reach this list in host mode (headless claims are
|
|
336
|
+
# command_only) — the guard below covers a backend that predates the
|
|
337
|
+
# filter, so a cron tick never swallows a packet nobody will read.
|
|
338
|
+
workflow_tasks = [t for t in tasks if _is_workflow_task(t)] if host_mode else []
|
|
339
|
+
|
|
340
|
+
results = [_execute_task(ctx, agent_id, task) for task in command_tasks]
|
|
341
|
+
failed = sum(1 for r in results if r["status"] == "failed")
|
|
342
|
+
_output(
|
|
343
|
+
ctx,
|
|
344
|
+
{
|
|
345
|
+
"agent_id": agent_id,
|
|
346
|
+
"tasks_run": len(results),
|
|
347
|
+
"failed": failed,
|
|
348
|
+
"results": results,
|
|
349
|
+
"workflow_tasks": len(workflow_tasks),
|
|
350
|
+
},
|
|
351
|
+
title="Task Queue",
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
# Workflow packets go last so the runtime contract (and its completion
|
|
355
|
+
# instructions) is the freshest thing in the host agent's context.
|
|
356
|
+
for task in workflow_tasks:
|
|
357
|
+
_emit_workflow_task(ctx, agent_id, task)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
@task_queue_group.command("list")
|
|
361
|
+
@click.option("--type", "agent_type", default=None, help="Resolve agent by type from local storage.")
|
|
362
|
+
@click.option("--role", "agent_role", default=None, help="Resolve agent by role (with --type).")
|
|
363
|
+
@click.pass_context
|
|
364
|
+
def task_queue_list(ctx: click.Context, agent_type: str | None, agent_role: str | None) -> None:
|
|
365
|
+
"""Show the task queue for this machine's agent.
|
|
366
|
+
|
|
367
|
+
Read-only.
|
|
368
|
+
"""
|
|
369
|
+
agent_id = _require_machine_agent_id(agent_type, agent_role)
|
|
370
|
+
data = _client(ctx).get(f"/agents/{agent_id}/task-queue")
|
|
371
|
+
if data.get("error"):
|
|
372
|
+
output_error(1, "Failed to list tasks", data["error"])
|
|
373
|
+
raise SystemExit(1)
|
|
374
|
+
tasks = data.get("tasks", [])
|
|
375
|
+
_output(
|
|
376
|
+
ctx,
|
|
377
|
+
{"agent_id": agent_id, "tasks": tasks, "count": len(tasks)},
|
|
378
|
+
columns=TASK_COLUMNS,
|
|
379
|
+
title="Task Queue",
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@task_queue_group.command("complete")
|
|
384
|
+
@click.argument("task_id")
|
|
385
|
+
@click.option(
|
|
386
|
+
"--status",
|
|
387
|
+
type=click.Choice(["completed", "failed"]),
|
|
388
|
+
required=True,
|
|
389
|
+
help="Terminal outcome of the workflow task.",
|
|
390
|
+
)
|
|
391
|
+
@click.option("--note", default=None, help="Short outcome note stored as the task's output tail.")
|
|
392
|
+
@click.option("--type", "agent_type", default=None, help="Resolve agent by type from local storage.")
|
|
393
|
+
@click.option("--role", "agent_role", default=None, help="Resolve agent by role (with --type).")
|
|
394
|
+
@click.pass_context
|
|
395
|
+
def task_queue_complete(
|
|
396
|
+
ctx: click.Context,
|
|
397
|
+
task_id: str,
|
|
398
|
+
status: str,
|
|
399
|
+
note: str | None,
|
|
400
|
+
agent_type: str | None,
|
|
401
|
+
agent_role: str | None,
|
|
402
|
+
) -> None:
|
|
403
|
+
"""Report the terminal outcome of a claimed workflow task (DV-955).
|
|
404
|
+
|
|
405
|
+
Called by the host agent after driving a webhook-queued workflow run to
|
|
406
|
+
its end (`deepvista skill complete`) — or to its failure. Plain command
|
|
407
|
+
tasks report automatically; this is only needed for workflow tasks,
|
|
408
|
+
which stay ``running`` until someone reports them.
|
|
409
|
+
"""
|
|
410
|
+
agent_id = _require_machine_agent_id(agent_type, agent_role)
|
|
411
|
+
data = _client(ctx).post(
|
|
412
|
+
f"/agents/{agent_id}/task-queue/{task_id}/result",
|
|
413
|
+
{"status": status, "exit_code": 0 if status == "completed" else 1, "output_tail": note},
|
|
414
|
+
)
|
|
415
|
+
if not data.get("success"):
|
|
416
|
+
output_error(1, "Failed to report task result", data.get("error", "Unknown error"))
|
|
417
|
+
raise SystemExit(1)
|
|
418
|
+
_output(ctx, {"agent_id": agent_id, "task": data.get("task")}, title="Task Queue")
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
# ---------------------------------------------------------------------------
|
|
422
|
+
# Cron setup
|
|
423
|
+
# ---------------------------------------------------------------------------
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _read_crontab() -> list[str]:
|
|
427
|
+
"""Current user crontab lines ([] when none exists)."""
|
|
428
|
+
try:
|
|
429
|
+
proc = subprocess.run( # noqa: S603
|
|
430
|
+
["crontab", "-l"], # noqa: S607
|
|
431
|
+
capture_output=True,
|
|
432
|
+
text=True,
|
|
433
|
+
timeout=10,
|
|
434
|
+
)
|
|
435
|
+
except (OSError, subprocess.SubprocessError):
|
|
436
|
+
return []
|
|
437
|
+
if proc.returncode != 0:
|
|
438
|
+
return []
|
|
439
|
+
return proc.stdout.splitlines()
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _write_crontab(lines: list[str]) -> bool:
|
|
443
|
+
content = "\n".join(lines) + ("\n" if lines else "")
|
|
444
|
+
try:
|
|
445
|
+
proc = subprocess.run( # noqa: S603
|
|
446
|
+
["crontab", "-"], # noqa: S607
|
|
447
|
+
input=content,
|
|
448
|
+
capture_output=True,
|
|
449
|
+
text=True,
|
|
450
|
+
timeout=10,
|
|
451
|
+
)
|
|
452
|
+
except (OSError, subprocess.SubprocessError):
|
|
453
|
+
return False
|
|
454
|
+
return proc.returncode == 0
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _cron_entry(interval: int, profile: str) -> str:
|
|
458
|
+
binary = _deepvista_binary()
|
|
459
|
+
profile_flag = f" --profile {profile}" if profile and profile != "default" else ""
|
|
460
|
+
return f"*/{interval} * * * * {binary}{profile_flag} task_queue run >> {CRON_LOG_PATH} 2>&1 {CRON_MARKER}"
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
@task_queue_group.command("setup")
|
|
464
|
+
@click.option(
|
|
465
|
+
"--interval",
|
|
466
|
+
default=5,
|
|
467
|
+
show_default=True,
|
|
468
|
+
type=click.IntRange(1, 1440),
|
|
469
|
+
help="Poll interval in minutes.",
|
|
470
|
+
)
|
|
471
|
+
@click.option("--remove", is_flag=True, default=False, help="Uninstall the cron entry instead.")
|
|
472
|
+
@click.pass_context
|
|
473
|
+
def task_queue_setup(ctx: click.Context, interval: int, remove: bool) -> None:
|
|
474
|
+
"""Install a crontab entry that runs `deepvista task_queue run` periodically.
|
|
475
|
+
|
|
476
|
+
Idempotent — re-running replaces any existing entry. Use --remove to
|
|
477
|
+
uninstall. Crontab only (macOS/Linux); on Windows, schedule
|
|
478
|
+
`deepvista task_queue run` with Task Scheduler instead.
|
|
479
|
+
|
|
480
|
+
Cron runs are headless: they execute plain command tasks only and
|
|
481
|
+
leave workflow tasks (webhook-queued skill runs) pending. Drive those
|
|
482
|
+
from an agent session with `deepvista task_queue run --host`.
|
|
483
|
+
|
|
484
|
+
> [!CAUTION] This is a write command — confirm with the user before executing.
|
|
485
|
+
"""
|
|
486
|
+
if sys.platform == "win32":
|
|
487
|
+
output_error(1, "Unsupported platform", "Crontab setup is macOS/Linux only.")
|
|
488
|
+
raise SystemExit(1)
|
|
489
|
+
|
|
490
|
+
existing = _read_crontab()
|
|
491
|
+
kept = [line for line in existing if CRON_MARKER not in line]
|
|
492
|
+
entry = None if remove else _cron_entry(interval, getattr(ctx.obj, "profile", "default"))
|
|
493
|
+
updated = kept + ([entry] if entry else [])
|
|
494
|
+
|
|
495
|
+
if ctx.obj.dry_run:
|
|
496
|
+
_output(
|
|
497
|
+
ctx,
|
|
498
|
+
{
|
|
499
|
+
"dry_run": True,
|
|
500
|
+
"would": "remove cron entry" if remove else "install cron entry",
|
|
501
|
+
"entry": entry,
|
|
502
|
+
"removed_entries": [line for line in existing if CRON_MARKER in line],
|
|
503
|
+
},
|
|
504
|
+
title="Dry Run: Task Queue Setup",
|
|
505
|
+
)
|
|
506
|
+
return
|
|
507
|
+
|
|
508
|
+
if remove and len(kept) == len(existing):
|
|
509
|
+
_output(ctx, {"removed": False, "message": "No task-queue cron entry installed."}, title="Task Queue Setup")
|
|
510
|
+
return
|
|
511
|
+
|
|
512
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
513
|
+
if not _write_crontab(updated):
|
|
514
|
+
output_error(1, "Failed to update crontab", "Is `crontab` available on this machine?")
|
|
515
|
+
raise SystemExit(1)
|
|
516
|
+
|
|
517
|
+
if remove:
|
|
518
|
+
_output(ctx, {"removed": True}, title="Task Queue Setup")
|
|
519
|
+
else:
|
|
520
|
+
_output(
|
|
521
|
+
ctx,
|
|
522
|
+
{"installed": True, "interval_minutes": interval, "entry": entry, "log": str(CRON_LOG_PATH)},
|
|
523
|
+
title="Task Queue Setup",
|
|
524
|
+
)
|