mirror of
https://github.com/tiennm99/claude-code-routine-trigger.git
synced 2026-10-13 02:14:32 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcb86bd09b | ||
|
|
5bb38fb220 | ||
|
|
5c5463057e | ||
|
|
1402187f2b | ||
|
|
e7151e1652 | ||
|
|
bcebe58cc2 | ||
|
|
1766168adc | ||
|
|
05ac7d7939 | ||
|
|
62272e4f6b | ||
|
|
b486ca5b39 | ||
|
|
d24dc83b9c | ||
|
|
95d9b6b5ee | ||
|
|
6cf3232854 | ||
|
|
1df67b0c8d | ||
|
|
4baa828831 | ||
|
|
7570bb7e23 | ||
|
|
22c4fae915 | ||
|
|
c5cb39b89a | ||
|
|
d786f993cc | ||
|
|
c7dcec1770 | ||
|
|
11b908cdac | ||
|
|
df7711231b | ||
|
|
723f5773fa | ||
|
|
4626889387 | ||
|
|
2721b214d9 | ||
|
|
d6c89ba803 | ||
|
|
1b9a6eba89 | ||
|
|
b26b101c9a | ||
|
|
a7ec7589a6 | ||
|
|
b20e3b38b6 | ||
|
|
919cccfcbd |
No files matched your search
@@ -4,6 +4,20 @@
|
||||
> **No longer used by the author.** Claude Code routines now ship with a **built-in cron trigger** in the routine editor — that's what I use now. Prefer the native scheduler; it runs on Anthropic's infra (no GitHub Actions delay/drop issues), needs no repo, no secrets, no workflow file.
|
||||
>
|
||||
> This repo is kept as a reference for anyone who still wants to fire routines from an external scheduler (GitHub Actions, own cron, Cloud Scheduler, etc.).
|
||||
>
|
||||
> If you want **self-hosted scheduling with sub-second precision** (no GitHub cron delays/drops), see [`self-hosted/`](./self-hosted/) — Go daemon shipped as a multi-arch Docker image on GHCR (`ghcr.io/tiennm99/claude-code-routine-cron`).
|
||||
|
||||
## Choose your deployment
|
||||
|
||||
| | **GitHub Actions** (repo root) | [`self-hosted/`](./self-hosted/) | [`cloudflare-worker/`](./cloudflare-worker/) |
|
||||
| ---------------- | -------------------------------------- | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| Runs on | GitHub Actions runners | your infra (Docker, k8s, NAS, RPi) | Cloudflare edge |
|
||||
| Cost | free (within GitHub minutes) | minimal (your infra) | free (CF free tier) |
|
||||
| Cron precision | ±30 min – 2 h, can drop runs | sub-second | within ~15 sec |
|
||||
| Setup | fork + 2 repo secrets | env vars + Docker | `wrangler deploy` + 2 secrets |
|
||||
| Audit trail | GitHub Actions runs page | container stdout | CF dashboard / `wrangler tail` |
|
||||
|
||||
Pick **GitHub Actions** if you want zero-infra and don't care about precise firing time. Pick **`self-hosted/`** for behind-firewall / on-prem / precise timing. Pick **`cloudflare-worker/`** for edge-native deploys with CF dashboard observability.
|
||||
|
||||
Scheduled GitHub Actions workflow that fires a [Claude Code routine](https://code.claude.com/docs/en/routines) via the [`/fire` API](https://platform.claude.com/docs/en/api/claude-code/routines-fire). Ships with a 5×-daily default cron and exposes a `workflow_dispatch` button for on-demand runs. Fire URL and per-routine token live in repo secrets.
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Copy this file to `.dev.vars` for `wrangler dev` local runs.
|
||||
# `.dev.vars` is gitignored. Real secrets go through `wrangler secret put` for production.
|
||||
ROUTINE_FIRE_URL=https://api.anthropic.com/v1/claude_code/routines/trig_REPLACE_ME/fire
|
||||
ROUTINE_FIRE_TOKEN=sk-ant-oat01-REPLACE_ME
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
- run: npm ci
|
||||
- run: npm test
|
||||
- run: npx wrangler deploy --dry-run
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: dummy
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.wrangler/
|
||||
.dev.vars
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
@@ -0,0 +1,23 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
- Enable Workers Logs with `head_sampling_rate = 1` and `invocation_logs = true` — every cron tick produces a full trace, browsable in the CF dashboard for 3 days.
|
||||
|
||||
## [0.1.0] - 2026-05-09
|
||||
|
||||
### Added
|
||||
- Cloudflare Workers `scheduled` handler that POSTs to the Claude Code routine `/fire` endpoint.
|
||||
- Default 20×daily cron at UTC+7 — fires at 00:00 and hourly from 05:00 through 23:00. Single cron expression (`0 0-17,22-23 * * *`).
|
||||
- Token-substitution template with `{ISO}`, `{LocalTime}`, `{Cron}` (default: `Scheduled trigger at {LocalTime}`).
|
||||
- IANA timezone support via `TZ` env var (default: `UTC`).
|
||||
- Structured JSON logs for fire success / non-2xx / network failure.
|
||||
- Vitest test suite covering template substitution, fire paths, header correctness, token redaction.
|
||||
- GitHub Actions CI: `npm test` + `wrangler deploy --dry-run` on push and PR.
|
||||
- Apache-2.0 license.
|
||||
@@ -0,0 +1,202 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 tiennm99
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
# claude-code-routine-trigger-worker
|
||||
|
||||
Cloudflare Worker that fires a [Claude Code routine](https://code.claude.com/docs/en/routines) on a precise cron schedule via the [`/fire` API](https://platform.claude.com/docs/en/api/claude-code/routines-fire). Runs on Cloudflare's free tier — no servers, no GitHub minutes, no Docker host.
|
||||
|
||||
> [!TIP]
|
||||
> **Anthropic's routine editor now ships a built-in cron trigger** — it runs on Anthropic's infra, no setup. Use it first.
|
||||
>
|
||||
> If you need an external scheduler (Anthropic cron disabled, custom payloads, audit trail outside Anthropic), I recommend **[cron-job.org](https://cron-job.org)** — free, no infra, ~2-min setup. I tried it on this exact routine and am satisfied, so I've parked this worker (cron triggers are commented out in `wrangler.toml`) to keep my CF cron-trigger quota free for other projects. See [Recommended: cron-job.org](#recommended-cron-joborg).
|
||||
>
|
||||
> This worker is still a valid path if you specifically want secrets in CF / logs in CF / schedule in code review. See [Using Cloudflare Workers](#using-cloudflare-workers).
|
||||
|
||||
## Recommended: cron-job.org
|
||||
|
||||
[cron-job.org](https://cron-job.org) is a free hosted cron service with a clean dashboard, per-fire history, and 1-min granularity on the free tier. Setup:
|
||||
|
||||
1. Sign up at [console.cron-job.org/signup](https://console.cron-job.org/signup).
|
||||
2. Click **CREATE CRONJOB**.
|
||||
3. **Common** tab:
|
||||
- **Title**: anything (e.g. `claude-code-routine`)
|
||||
- **URL**: paste the `/fire` URL from the Anthropic routine editor → *API trigger* → *URL* (looks like `https://api.anthropic.com/v1/claude_code/routines/trig_.../fire`)
|
||||
- **Schedule**: pick your timezone and the times to fire — cron-job.org accepts both UI selectors and raw cron syntax
|
||||
4. **Advanced** tab → set **Request method** to `POST`.
|
||||
5. **Headers** tab → add four headers:
|
||||
|
||||
| Key | Value |
|
||||
| ------------------- | ---------------------------------------------- |
|
||||
| `Authorization` | `Bearer sk-ant-oat01-...` (your routine token) |
|
||||
| `anthropic-version` | `2023-06-01` |
|
||||
| `anthropic-beta` | `experimental-cc-routine-2026-04-01` |
|
||||
| `Content-Type` | `application/json` |
|
||||
|
||||
6. **Body** tab → select `Raw` and paste:
|
||||
```json
|
||||
{"text": "Scheduled trigger"}
|
||||
```
|
||||
7. **Notifications** tab (optional) → enable email on failure so you know if the token expires.
|
||||
8. **Save**.
|
||||
|
||||
Each fire shows up in *History* with status code and response body — open `claude_code_session_url` from the response JSON to watch the run.
|
||||
|
||||
**Operational notes:**
|
||||
- **Token rotation**: edit the cronjob → swap the `Authorization` header value. No redeploy.
|
||||
- **Beta header**: when Anthropic ships a new dated `anthropic-beta` value, update the header. Older dated values keep working for a transition window per Anthropic's beta policy.
|
||||
- **No retry on failure**: each `/fire` POST creates a new Claude Code session, so retrying would multiply sessions and burn quota. cron-job.org's default is one attempt per fire, which is what you want.
|
||||
- **Limits**: cron-job.org's free tier allows up to 50 cronjobs and unlimited executions at 1-min granularity — way more than enough for routine triggering.
|
||||
|
||||
## Why this vs the siblings
|
||||
|
||||
| | [GitHub Actions](../) (repo root) | [`self-hosted/`](../self-hosted/) | **cloudflare-worker** (this directory) |
|
||||
| ---------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | --------------------------------------------- |
|
||||
| Runs on | GitHub Actions runners | your infra (Docker host, k8s, NAS, RPi) | Cloudflare edge |
|
||||
| Cost | free (within GitHub minutes) | minimal (your infra) | free (CF free tier) |
|
||||
| Cron precision | ±30 min – 2 h, can drop runs | sub-second | within ~15 sec |
|
||||
| Setup | fork + 2 repo secrets | env vars + Docker | `wrangler deploy` + 2 secrets |
|
||||
| Audit trail | GitHub Actions runs page | container stdout | CF dashboard / `wrangler tail` |
|
||||
|
||||
## Using Cloudflare Workers
|
||||
|
||||
> The default `wrangler.toml` ships with the `[triggers]` block **commented out** — see the note in the file. To activate this worker, uncomment the block (and edit the schedule) before deploying.
|
||||
|
||||
### Quickstart
|
||||
|
||||
```bash
|
||||
git clone https://github.com/tiennm99/claude-code-routine-trigger
|
||||
cd claude-code-routine-trigger/cloudflare-worker
|
||||
npm install
|
||||
|
||||
# Upload secrets (from Anthropic routine editor → API trigger)
|
||||
echo -n 'https://api.anthropic.com/v1/claude_code/routines/trig_.../fire' \
|
||||
| npx wrangler secret put ROUTINE_FIRE_URL
|
||||
echo -n 'sk-ant-oat01-...' \
|
||||
| npx wrangler secret put ROUTINE_FIRE_TOKEN
|
||||
|
||||
# Uncomment [triggers].crons in wrangler.toml, edit the schedule + timezone, then:
|
||||
npx wrangler deploy
|
||||
```
|
||||
|
||||
Tail logs:
|
||||
|
||||
```bash
|
||||
npx wrangler tail
|
||||
```
|
||||
|
||||
A successful fire logs a JSON line with `session_url`. Open it to watch the run.
|
||||
|
||||
### Environment variables
|
||||
|
||||
Configured in `wrangler.toml` (`[vars]` for plain values) or via `wrangler secret put` (for secrets).
|
||||
|
||||
| Name | Type | Required | Default | Notes |
|
||||
| -------------------- | ------ | :------: | ---------------------------------------- | ----- |
|
||||
| `ROUTINE_FIRE_URL` | secret | yes | — | Anthropic `/fire` endpoint. From routine editor → API trigger. |
|
||||
| `ROUTINE_FIRE_TOKEN` | secret | yes | — | `sk-ant-oat01-...` per-routine token. Shown once in the editor. |
|
||||
| `TEXT_TEMPLATE` | var | no | `Scheduled trigger at {LocalTime}` | Token-substitution template. See *Templates*. |
|
||||
| `TZ` | var | no | `UTC` | IANA tz name (`Asia/Ho_Chi_Minh`, `America/New_York`, …). Used for `{LocalTime}` formatting. |
|
||||
|
||||
### Customize the schedule
|
||||
|
||||
Uncomment and edit `wrangler.toml` `[triggers].crons` — Cloudflare requires literal cron expressions (same constraint as GitHub Actions). To change when the routine fires:
|
||||
|
||||
```toml
|
||||
[triggers]
|
||||
crons = [
|
||||
"0 0-17,22-23 * * *", # UTC+7: 00:00 + 05:00..23:00 hourly
|
||||
]
|
||||
```
|
||||
|
||||
Then redeploy: `npx wrangler deploy`.
|
||||
|
||||
Tips:
|
||||
- Cron runs in **UTC**. Convert your local time: `UTC = local − offset` (e.g. 09:00 UTC+7 → 02:00 UTC → `0 2 * * *`).
|
||||
- Validate expressions at <https://crontab.guru/>.
|
||||
- **Free tier limit:** 5 cron expressions per worker. Default config uses 1.
|
||||
- Standard 5-field syntax — supports `*`, `,`, `-`, `/`. Comma lists (`22,23,0,1,2`) and ranges (`3-7`) let you cram many fires into one expression.
|
||||
|
||||
### Templates
|
||||
|
||||
`TEXT_TEMPLATE` supports these `{Token}` substitutions, rendered per fire. Unknown tokens are left intact in the output.
|
||||
|
||||
| Token | Example |
|
||||
| ------------- | -------------------------------------- |
|
||||
| `{ISO}` | `2026-05-09T03:19:00.000Z` |
|
||||
| `{LocalTime}` | `2026-05-09 10:19 GMT+7` |
|
||||
| `{Cron}` | `19 3 * * *` — the expression that fired |
|
||||
|
||||
Example:
|
||||
|
||||
```toml
|
||||
[vars]
|
||||
TEXT_TEMPLATE = "Daily digest at {LocalTime} (cron {Cron})"
|
||||
TZ = "America/New_York"
|
||||
```
|
||||
|
||||
### Local development
|
||||
|
||||
Copy `.dev.vars.example` to `.dev.vars` and fill in your routine credentials:
|
||||
|
||||
```bash
|
||||
cp .dev.vars.example .dev.vars
|
||||
# edit .dev.vars
|
||||
|
||||
npx wrangler dev --test-scheduled
|
||||
```
|
||||
|
||||
Then trigger a scheduled run:
|
||||
|
||||
```bash
|
||||
curl "http://localhost:8787/__scheduled?cron=*+*+*+*+*"
|
||||
```
|
||||
|
||||
`.dev.vars` is gitignored — never commit it.
|
||||
|
||||
### Tests
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
Vitest runs in the Workers runtime via `@cloudflare/vitest-pool-workers`. Tests mock `fetch`, so they consume no Anthropic quota.
|
||||
|
||||
### Secret rotation
|
||||
|
||||
`wrangler secret put` overwrites silently. Rotate by re-running it with the new value:
|
||||
|
||||
```bash
|
||||
echo -n 'sk-ant-oat01-newvalue' | npx wrangler secret put ROUTINE_FIRE_TOKEN
|
||||
```
|
||||
|
||||
The change takes effect on the next deploy or within a few seconds via the live config.
|
||||
|
||||
### Beta header
|
||||
|
||||
The request pins `anthropic-beta: experimental-cc-routine-2026-04-01` (constant in `worker.js`). When Anthropic ships a new dated beta, bump it via a release. Older dated values keep working for a transition window per Anthropic's beta policy.
|
||||
|
||||
### Operational notes
|
||||
|
||||
- **Time accuracy**: cron precision on CF Workers is within ~15 seconds — adequate for routine triggering.
|
||||
- **No retry**: each `/fire` POST creates a new Claude Code session — retrying multiplies sessions and burns quota. The worker logs failures and moves on.
|
||||
- **Logs / traces**: `[observability]` is enabled in `wrangler.toml` with `head_sampling_rate = 1` and `invocation_logs = true` — every invocation produces a structured log + trace, retained per the [Workers Logs retention policy](https://developers.cloudflare.com/workers/observability/logs/workers-logs/) (3 days on the free plan). View live with `npx wrangler tail`, or browse history in the CF dashboard under **Workers & Pages → your worker → Logs**.
|
||||
- **Cost**: scheduled handlers count against the Workers Free plan's 100k requests/day budget. 5 daily fires × 30 days = 150 requests/month — negligible.
|
||||
|
||||
### Security
|
||||
|
||||
- The token is **per-routine**: a leak only fires that one routine.
|
||||
- Secrets live in CF's encrypted secret store, never in the bundle, never in logs (verified by tests).
|
||||
- TLS to `api.anthropic.com` uses CF's standard cert verification.
|
||||
- `worker.js` has no `fetch` HTTP handler — the worker is unreachable from the public internet, only fires on cron tick.
|
||||
|
||||
## License
|
||||
|
||||
[Apache-2.0](./LICENSE)
|
||||
Generated
+2974
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "claude-code-routine-trigger-worker",
|
||||
"version": "0.0.0",
|
||||
"description": "Cloudflare Workers cron port of claude-code-routine-cron — fires Claude Code routines on a schedule.",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"main": "worker.js",
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vitest-pool-workers": "^0.22.0",
|
||||
"vite": "^8.3.2",
|
||||
"vitest": "^4.1.11",
|
||||
"wrangler": "^4.147.0"
|
||||
},
|
||||
"allowScripts": {
|
||||
"esbuild": true,
|
||||
"workerd": true
|
||||
},
|
||||
"overrides": {
|
||||
"sharp": "^0.35.5",
|
||||
"undici": "^7.30.0"
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
---
|
||||
phase: 1
|
||||
title: Repo scaffold
|
||||
status: completed
|
||||
priority: P1
|
||||
effort: 1h
|
||||
dependencies: []
|
||||
---
|
||||
|
||||
# Phase 1: Repo scaffold
|
||||
|
||||
## Overview
|
||||
Greenfield repo init. Apache-2.0 license, README skeleton, JS + wrangler toolchain, gitignore. No transpile step — Workers runtime executes ES modules natively.
|
||||
|
||||
## Requirements
|
||||
- **Functional:** `npm install` succeeds; `wrangler deploy --dry-run` validates an empty `worker.js` handler.
|
||||
- **Non-functional:** No secrets in repo. Pin wrangler major version. kebab-case file names.
|
||||
|
||||
## Architecture
|
||||
Flat layout — single-purpose worker, no need for src/ subdirs (KISS).
|
||||
|
||||
```
|
||||
claude-code-routine-trigger-worker/
|
||||
├── .github/workflows/ci.yml # phase 4
|
||||
├── .gitignore
|
||||
├── LICENSE # Apache-2.0
|
||||
├── README.md # phase 5
|
||||
├── package.json
|
||||
├── wrangler.toml # phase 3
|
||||
├── worker.js # phase 2
|
||||
└── worker.test.js # phase 4
|
||||
```
|
||||
|
||||
## Related Code Files
|
||||
- Create: `LICENSE` (Apache-2.0 verbatim)
|
||||
- Create: `.gitignore` (node_modules, .dev.vars, .wrangler, dist, .env*)
|
||||
- Create: `package.json` (name, version 0.0.0, `"type": "module"`, scripts: test, deploy, dev)
|
||||
- Create: `README.md` (one-liner placeholder; full content in phase 5)
|
||||
|
||||
## Implementation Steps
|
||||
1. `cd /config/workspace/tiennm99/claude-code-routine-trigger-worker && git init -b main`
|
||||
2. Write `LICENSE` (copy Apache-2.0 from sibling `claude-code-routine-cron/LICENSE`).
|
||||
3. Write `.gitignore`:
|
||||
```
|
||||
node_modules/
|
||||
dist/
|
||||
.wrangler/
|
||||
.dev.vars
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
```
|
||||
4. Write `package.json` with `"type": "module"`, devDeps: `wrangler` (^4), `vitest` (phase 4), `@cloudflare/vitest-pool-workers` (phase 4).
|
||||
Scripts:
|
||||
- `dev`: `wrangler dev`
|
||||
- `deploy`: `wrangler deploy`
|
||||
- `test`: `vitest run`
|
||||
5. Run `npm install` — confirm lockfile generated.
|
||||
6. Stub `README.md` with one-liner: `> Cloudflare Workers cron port of claude-code-routine-cron — fires Claude Code routines on a schedule.`
|
||||
7. First commit: `chore: init repo scaffold`.
|
||||
|
||||
## Success Criteria
|
||||
- [ ] `npm install` exits 0
|
||||
- [ ] `wrangler deploy --dry-run` validates empty `worker.js` exporting `export default {}`
|
||||
- [ ] `git status` clean after first commit
|
||||
- [ ] LICENSE file matches Apache-2.0 exactly
|
||||
- [ ] `package.json` has `"type": "module"`
|
||||
|
||||
## Risk Assessment
|
||||
- **Wrangler version drift:** pin to current major (e.g. `^4.0.0`); document upgrade path in README.
|
||||
- **Node version:** wrangler 4 needs Node ≥18; document in README prereqs.
|
||||
- **No type safety:** mitigate by JSDoc type hints in `worker.js` for `Env` and handler signatures (cheap, optional, no toolchain).
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
---
|
||||
phase: 2
|
||||
title: Worker handler + fire client
|
||||
status: completed
|
||||
priority: P1
|
||||
effort: 2-3h
|
||||
dependencies:
|
||||
- 1
|
||||
---
|
||||
|
||||
# Phase 2: Worker handler + fire client
|
||||
|
||||
## Overview
|
||||
Implement the `scheduled` handler in plain JS with JSDoc type annotations: render text template, POST to `/fire`, log structured JSON. Single `worker.js` file. No `fetch` handler (per locked decision: scheduled-only).
|
||||
|
||||
## Requirements
|
||||
- **Functional:**
|
||||
- On cron tick, POSTs to `ROUTINE_FIRE_URL` with bearer token, beta header, JSON body.
|
||||
- Logs `claude_code_session_url` on 2xx; logs status + body on non-2xx; never throws (would crash CF retry).
|
||||
- Renders `TEXT_TEMPLATE` with `{LocalTime}`, `{Cron}`, `{ISO}` placeholders.
|
||||
- No retry (each `/fire` = new session).
|
||||
- **Non-functional:**
|
||||
- Token never logged.
|
||||
- Use `console.log(JSON.stringify({...}))` for structured logs (CF picks these up).
|
||||
- JSDoc `@typedef Env` for environment bindings + `@param`/`@returns` on all exports.
|
||||
- Total file ≤ 200 lines (split if exceeds).
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
ScheduledEvent (cron, scheduledTime)
|
||||
│
|
||||
▼
|
||||
fireRoutine(env, controller)
|
||||
├── renderText(template, vars) # simple {Tok} substitution
|
||||
├── fetch(URL, POST, headers, body)
|
||||
└── log(json) # success or error
|
||||
```
|
||||
|
||||
### Mapping from Go daemon
|
||||
| Go (claude-code-routine-cron) | JavaScript (this worker) |
|
||||
|---|---|
|
||||
| `Config.FireURL` env | `env.ROUTINE_FIRE_URL` secret |
|
||||
| `Config.Token` env | `env.ROUTINE_FIRE_TOKEN` secret |
|
||||
| `Config.Schedules` env | `wrangler.toml` `[triggers].crons` (literal) |
|
||||
| `Config.Location` (TZ) | `env.TZ` plain var, default `UTC` |
|
||||
| `Config.Template` (Go text/template) | `env.TEXT_TEMPLATE` plain string + `{Tok}` substitution |
|
||||
| `FireClient.Fire()` | `fireRoutine()` function |
|
||||
| `cron.New()` scheduler | CF Workers scheduled handler (built-in) |
|
||||
| `slog` JSON logs | `console.log(JSON.stringify(...))` |
|
||||
|
||||
### JSDoc typedefs
|
||||
```js
|
||||
/**
|
||||
* @typedef {object} Env
|
||||
* @property {string} ROUTINE_FIRE_URL - secret: Anthropic /fire endpoint
|
||||
* @property {string} ROUTINE_FIRE_TOKEN - secret: per-routine bearer token
|
||||
* @property {string} [TEXT_TEMPLATE] - plain var: prompt template, supports {LocalTime} {Cron} {ISO}
|
||||
* @property {string} [TZ] - plain var: IANA tz, default 'UTC'
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} FireResponse
|
||||
* @property {string} type
|
||||
* @property {string} claude_code_session_id
|
||||
* @property {string} claude_code_session_url
|
||||
*/
|
||||
```
|
||||
|
||||
### Headers (verbatim from siblings)
|
||||
```
|
||||
Authorization: Bearer ${token}
|
||||
anthropic-version: 2023-06-01
|
||||
anthropic-beta: experimental-cc-routine-2026-04-01
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
### Body
|
||||
```json
|
||||
{ "text": "<rendered template>" }
|
||||
```
|
||||
|
||||
### Template substitution (KISS — not Go text/template)
|
||||
| Token | Value |
|
||||
|---------------|-------------------------------------------------------|
|
||||
| `{ISO}` | `new Date(scheduledTime).toISOString()` |
|
||||
| `{LocalTime}` | formatted via `Intl.DateTimeFormat(env.TZ ?? 'UTC')` |
|
||||
| `{Cron}` | `controller.cron` |
|
||||
|
||||
Default template: `Scheduled trigger at {LocalTime}` — same default as Go daemon.
|
||||
|
||||
## Related Code Files
|
||||
- Create: `worker.js` (export default `{ scheduled }`, JSDoc-annotated)
|
||||
- (Optional split if >200 lines) `fire-client.js`, `template-renderer.js`
|
||||
|
||||
## Implementation Steps
|
||||
1. Top of `worker.js`: write JSDoc `@typedef Env` + `@typedef FireResponse` blocks.
|
||||
2. Implement `renderText(template, vars)` with JSDoc:
|
||||
```js
|
||||
/**
|
||||
* @param {string} template
|
||||
* @param {Record<string, string>} vars
|
||||
* @returns {string}
|
||||
*/
|
||||
```
|
||||
Replaces `{Key}` tokens, leaves unknown tokens intact.
|
||||
3. Implement `formatLocalTime(date, tz)` using `Intl.DateTimeFormat`. JSDoc: `@param {Date} date`, `@param {string} tz`, `@returns {string}`.
|
||||
4. Implement `fireRoutine(env, controller)` with JSDoc `@param {Env} env`, `@param {ScheduledController} controller`, `@returns {Promise<void>}`:
|
||||
- Build `text` via `renderText`.
|
||||
- `fetch(env.ROUTINE_FIRE_URL, { method: 'POST', headers, body: JSON.stringify({text}) })`.
|
||||
- On `!response.ok`: `console.log(JSON.stringify({level:'error', cron, status, body}))`.
|
||||
- On `ok`: parse JSON, log `{level:'info', cron, session_url, session_id}`.
|
||||
- Wrap in try/catch — log network errors, don't rethrow.
|
||||
5. Export default with JSDoc `@type {ExportedHandler<Env>}`:
|
||||
```js
|
||||
/** @type {ExportedHandler<Env>} */
|
||||
export default {
|
||||
async scheduled(controller, env, ctx) {
|
||||
ctx.waitUntil(fireRoutine(env, controller));
|
||||
},
|
||||
};
|
||||
```
|
||||
6. `npx tsc --noEmit --allowJs --checkJs worker.js --target es2022 --module esnext --moduleResolution bundler --types @cloudflare/workers-types` (one-shot smoke check; not added to scripts to avoid TS dependency creep). Optional — skip if undesired.
|
||||
7. Local smoke: `wrangler dev --test-scheduled`, then `curl "http://localhost:8787/__scheduled?cron=*+*+*+*+*"` — confirm log line.
|
||||
|
||||
## Success Criteria
|
||||
- [ ] `worker.js` ≤ 200 lines (split if needed)
|
||||
- [ ] All exported functions have JSDoc with `@param` + `@returns`
|
||||
- [ ] `wrangler deploy --dry-run` validates module
|
||||
- [ ] Token never appears in logged output (manual grep on `wrangler dev` output)
|
||||
- [ ] Local `__scheduled` test produces structured JSON log
|
||||
|
||||
## Risk Assessment
|
||||
- **`Intl.DateTimeFormat` TZ support on Workers runtime:** supported since 2023; verify with smoke test.
|
||||
- **`ctx.waitUntil` vs awaiting in `scheduled`:** `waitUntil` ensures fire completes even if handler returns; preferred per CF docs.
|
||||
- **No compile-time type errors:** JSDoc + editor LSP catches most; runtime tests (phase 4) catch the rest.
|
||||
- **Template injection:** `text` is sent only to Anthropic API; no XSS / SQL surface — safe.
|
||||
|
||||
## Security Considerations
|
||||
- Token comes only from `env.ROUTINE_FIRE_TOKEN` (CF Secret), never bundled.
|
||||
- `console.log` body must not include `Authorization` header value.
|
||||
- TLS to `api.anthropic.com` is automatic (Workers `fetch` validates certs).
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
---
|
||||
phase: 3
|
||||
title: wrangler config + secrets
|
||||
status: completed
|
||||
priority: P1
|
||||
effort: 1h
|
||||
dependencies:
|
||||
- 1
|
||||
- 2
|
||||
---
|
||||
|
||||
# Phase 3: wrangler config + secrets
|
||||
|
||||
## Overview
|
||||
Author `wrangler.toml` with literal `[triggers].crons`, define plain vars vs secrets, document deployment flow. CF Workers cron triggers fire from `wrangler.toml` only — same constraint as GH Actions.
|
||||
|
||||
## Requirements
|
||||
- **Functional:**
|
||||
- `wrangler deploy` succeeds with valid `wrangler.toml`.
|
||||
- At least one cron expression in `[triggers]` (default 5×daily mirroring sibling repos).
|
||||
- Secrets uploaded out-of-band (not in `wrangler.toml`).
|
||||
- **Non-functional:**
|
||||
- All comments above each block explain the field.
|
||||
- Default crons use scattered minutes (avoid minute `0`) per `claude-code-routine-trigger` README warning — though CF cron is more reliable than GH, scattering is still good hygiene.
|
||||
|
||||
## Architecture
|
||||
|
||||
### `wrangler.toml` shape
|
||||
```toml
|
||||
name = "claude-code-routine-trigger-worker"
|
||||
main = "worker.js"
|
||||
compatibility_date = "2026-05-09"
|
||||
compatibility_flags = ["nodejs_compat"] # only if Intl needs polyfill — verify in phase 2
|
||||
|
||||
# Plain vars (visible in dashboard, fine for non-secrets)
|
||||
[vars]
|
||||
TEXT_TEMPLATE = "Scheduled trigger at {LocalTime}"
|
||||
TZ = "Asia/Ho_Chi_Minh"
|
||||
|
||||
# Cron triggers — LITERAL, cannot read from env/secrets/vars
|
||||
[triggers]
|
||||
crons = [
|
||||
"7 17 * * *", # 00:07 UTC+7
|
||||
"13 22 * * *", # 05:13 UTC+7
|
||||
"19 3 * * *", # 10:19 UTC+7
|
||||
"23 8 * * *", # 15:23 UTC+7
|
||||
"37 13 * * *", # 20:37 UTC+7
|
||||
]
|
||||
|
||||
# Observability — opt-in, free tier supports basic logs
|
||||
[observability]
|
||||
enabled = true
|
||||
```
|
||||
|
||||
### Secrets (uploaded via `wrangler secret put`)
|
||||
| Name | Source |
|
||||
|----------------------|-------------------------------------|
|
||||
| `ROUTINE_FIRE_URL` | Anthropic routine editor → API trigger |
|
||||
| `ROUTINE_FIRE_TOKEN` | Anthropic routine editor (shown once) |
|
||||
|
||||
### Local dev: `.dev.vars`
|
||||
```
|
||||
ROUTINE_FIRE_URL=https://api.anthropic.com/v1/claude_code/routines/trig_.../fire
|
||||
ROUTINE_FIRE_TOKEN=sk-ant-oat01-...
|
||||
```
|
||||
Gitignored. Used by `wrangler dev` only.
|
||||
|
||||
### `wrangler.toml.example`
|
||||
Committed copy with placeholder values for documentation; real `wrangler.toml` ships with author's defaults but secrets are NEVER literal.
|
||||
|
||||
## Related Code Files
|
||||
- Create: `wrangler.toml`
|
||||
- Create: `wrangler.toml.example` (or document in README — KISS, prefer README)
|
||||
- Create: `.dev.vars.example` with placeholder secret names + dummy values
|
||||
|
||||
## Implementation Steps
|
||||
1. Write `wrangler.toml` per architecture block above.
|
||||
- Pick `compatibility_date` = today (2026-05-09).
|
||||
- Decide on `nodejs_compat` flag — only enable if phase 2 needs it (`Intl` is built-in, likely **don't need it**).
|
||||
2. Write `.dev.vars.example`:
|
||||
```
|
||||
ROUTINE_FIRE_URL=https://api.anthropic.com/v1/claude_code/routines/trig_REPLACE/fire
|
||||
ROUTINE_FIRE_TOKEN=sk-ant-oat01-REPLACE
|
||||
```
|
||||
3. Verify `.gitignore` includes `.dev.vars` (added in phase 1).
|
||||
4. Local validation: `wrangler dev` — must boot without error.
|
||||
5. Document secret-upload flow in phase 5 README:
|
||||
```bash
|
||||
echo -n 'https://...' | wrangler secret put ROUTINE_FIRE_URL
|
||||
echo -n 'sk-ant-oat01-...' | wrangler secret put ROUTINE_FIRE_TOKEN
|
||||
```
|
||||
6. Commit: `feat: wrangler config with default 5x-daily crons`.
|
||||
|
||||
## Success Criteria
|
||||
- [ ] `wrangler dev` starts cleanly with `.dev.vars` populated
|
||||
- [ ] `wrangler deploy --dry-run` validates `wrangler.toml` without errors
|
||||
- [ ] All 5 default crons match sibling repos' cadence (UTC+7 daily 00/05/10/15/20)
|
||||
- [ ] No secret values in any committed file
|
||||
- [ ] `.dev.vars` confirmed in `.gitignore`
|
||||
|
||||
## Risk Assessment
|
||||
- **CF cron expression syntax:** CF Workers cron supports standard 5-field; `*/N` and ranges work. Avoid `?` (Quartz-only).
|
||||
- **Minute-`0` GH issue does not apply to CF** but scattering minutes is still recommended (free tier shared infra benefits).
|
||||
- **Free tier limit:** 5 cron triggers per worker. Default config uses exactly 5 — at limit. Document that adding a 6th requires another worker.
|
||||
|
||||
## Security Considerations
|
||||
- Secrets never in `wrangler.toml` (only `[vars]` for non-secrets).
|
||||
- `.dev.vars` is gitignored.
|
||||
- `wrangler secret put` accepts stdin, avoiding shell history leak (use `echo -n ... | wrangler secret put`).
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
---
|
||||
phase: 4
|
||||
title: Tests + CI
|
||||
status: completed
|
||||
priority: P2
|
||||
effort: 2h
|
||||
dependencies:
|
||||
- 2
|
||||
- 3
|
||||
---
|
||||
|
||||
# Phase 4: Tests + CI
|
||||
|
||||
## Overview
|
||||
Vitest with `@cloudflare/vitest-pool-workers` for in-Workers-runtime tests. Cover template rendering, fire client behavior (success / non-2xx / network error), token redaction. GitHub Actions CI on push.
|
||||
|
||||
## Requirements
|
||||
- **Functional:**
|
||||
- Tests cover: template substitution, missing-token rendering, fire success path, non-2xx path, fetch throws path.
|
||||
- CI runs `npm test` on push to `main` and PRs.
|
||||
- **Non-functional:**
|
||||
- Coverage report (text summary in CI logs).
|
||||
- Tests use mocked `fetch` — no real network calls (don't burn user's Anthropic quota).
|
||||
- Tests written in plain JS with JSDoc, matching production code style.
|
||||
- Race-free is N/A on Workers; instead assert `console.log` output never contains token.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Test layout
|
||||
```
|
||||
worker.test.js # all tests in one file initially (KISS)
|
||||
```
|
||||
Split if exceeds 200 lines.
|
||||
|
||||
### Test cases (mirror Go daemon's test coverage)
|
||||
| # | Case | Assertion |
|
||||
|---|------|-----------|
|
||||
| 1 | `renderText` substitutes `{LocalTime}`, `{Cron}`, `{ISO}` | output equals expected string |
|
||||
| 2 | `renderText` leaves unknown `{Foo}` tokens intact | output contains `{Foo}` |
|
||||
| 3 | `fireRoutine` 2xx response logs `session_url` | console captured contains `session_url` |
|
||||
| 4 | `fireRoutine` 401 response logs `level:error` | captured log has `level:'error'`, `status:401` |
|
||||
| 5 | `fireRoutine` `fetch` rejects (network) logs error, doesn't throw | no thrown exception, error log present |
|
||||
| 6 | Token redaction | for cases 3, 4, 5: stringified logs do NOT contain token value |
|
||||
| 7 | Headers correctness | mocked `fetch` receives `Authorization: Bearer ${token}`, `anthropic-version`, `anthropic-beta` |
|
||||
| 8 | Body shape | request body parses as `{ "text": <rendered> }` |
|
||||
| 9 | Default template when `TEXT_TEMPLATE` unset | output starts with `Scheduled trigger at ` |
|
||||
| 10 | TZ defaults to UTC when `env.TZ` unset | local time formatting uses UTC |
|
||||
|
||||
### CI workflow
|
||||
`.github/workflows/ci.yml` — single job, Node 20, runs test + wrangler dry-run as smoke gate.
|
||||
|
||||
## Related Code Files
|
||||
- Create: `worker.test.js`
|
||||
- Create: `vitest.config.js` (uses `@cloudflare/vitest-pool-workers`)
|
||||
- Create: `.github/workflows/ci.yml`
|
||||
|
||||
## Implementation Steps
|
||||
1. Install: `npm i -D vitest @cloudflare/vitest-pool-workers`.
|
||||
2. Write `vitest.config.js`:
|
||||
```js
|
||||
import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config';
|
||||
export default defineWorkersConfig({
|
||||
test: { poolOptions: { workers: { wrangler: { configPath: './wrangler.toml' } } } },
|
||||
});
|
||||
```
|
||||
3. Write `worker.test.js` covering all 10 cases. Use `vi.fn()` mock for `globalThis.fetch`. JSDoc-annotate test helper functions for IDE assist.
|
||||
4. Run `npm test` — all green.
|
||||
5. Write `.github/workflows/ci.yml`:
|
||||
```yaml
|
||||
name: ci
|
||||
on:
|
||||
push: { branches: [main] }
|
||||
pull_request:
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: '20', cache: 'npm' }
|
||||
- run: npm ci
|
||||
- run: npm test
|
||||
- run: npx wrangler deploy --dry-run
|
||||
```
|
||||
6. Push to GitHub; verify green CI.
|
||||
|
||||
## Success Criteria
|
||||
- [ ] All 10 test cases pass locally
|
||||
- [ ] `npm test` exits 0 in CI
|
||||
- [ ] `wrangler deploy --dry-run` exits 0 in CI
|
||||
- [ ] No real HTTP traffic generated by tests (verify by mock spy)
|
||||
- [ ] Coverage ≥ 80% (track via vitest `--coverage`; not enforced as blocking gate)
|
||||
|
||||
## Risk Assessment
|
||||
- **Workers test pool quirks:** `@cloudflare/vitest-pool-workers` requires wrangler config; ensure `wrangler.toml` validates first.
|
||||
- **Mocking `fetch` inside Workers runtime:** verify pattern works — fall back to dependency-injected fetch if not.
|
||||
- **Beta header pinning in tests:** assert exact value to catch accidental edits.
|
||||
|
||||
## Security Considerations
|
||||
- Test fixtures use placeholder token `sk-ant-oat01-test-only` — never a real value.
|
||||
- Test asserts logs **don't contain** the token (regression guard).
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
---
|
||||
phase: 5
|
||||
title: Docs + release
|
||||
status: completed
|
||||
priority: P2
|
||||
effort: 1.5h
|
||||
dependencies:
|
||||
- 1
|
||||
- 2
|
||||
- 3
|
||||
- 4
|
||||
---
|
||||
|
||||
# Phase 5: Docs + release
|
||||
|
||||
## Overview
|
||||
Full README mirroring sibling repos' tone, comparison-table back-link in both siblings, v0.1.0 GitHub release, public deployment of the author's own instance.
|
||||
|
||||
## Requirements
|
||||
- **Functional:**
|
||||
- README covers: why-this-vs-siblings, quickstart, env vars, secret upload, customize schedule, beta header policy, security, license.
|
||||
- GitHub release `v0.1.0` tagged.
|
||||
- Both sibling READMEs updated with a row pointing to this repo.
|
||||
- **Non-functional:**
|
||||
- Same prose voice as siblings (`> [!TIP]` / `> [!WARNING]` callouts, terse).
|
||||
- Code blocks copy-pasteable.
|
||||
- All claims about CF cron precision sourced (link CF docs).
|
||||
|
||||
## Architecture
|
||||
|
||||
### README sections (in order)
|
||||
1. **Header + tagline** — one-liner.
|
||||
2. **Why this vs siblings** — three-column comparison table.
|
||||
3. **Quickstart** — `wrangler deploy` + 2 secret-puts + done.
|
||||
4. **Environment variables** — table (name, required, default, notes).
|
||||
5. **Multiple schedules** — `wrangler.toml` `[triggers].crons` array.
|
||||
6. **Templates** — `{LocalTime}`, `{Cron}`, `{ISO}`.
|
||||
7. **Customize schedule** — edit `wrangler.toml`, `wrangler deploy`.
|
||||
8. **Local development** — `.dev.vars` + `wrangler dev --test-scheduled`.
|
||||
9. **Secret rotation** — `wrangler secret put` overwrites.
|
||||
10. **CF Workers free tier** — link, note 5-cron-per-worker limit.
|
||||
11. **Beta header** — pinned value, bump policy.
|
||||
12. **Operational notes** — log access (`wrangler tail`), no idempotency.
|
||||
13. **License** — Apache-2.0.
|
||||
|
||||
### Sibling repo updates
|
||||
- Edit `claude-code-routine-trigger/README.md` — add row in the "no longer used by author" callout listing `trigger-worker` as another option.
|
||||
- Edit `claude-code-routine-cron/README.md` — extend "Why this vs `claude-code-routine-trigger`" table to a 3-way comparison including `trigger-worker`.
|
||||
|
||||
## Related Code Files
|
||||
- Modify: `README.md` (this repo)
|
||||
- Modify (out of repo, requires PR/commit): `/config/workspace/tiennm99/claude-code-routine-trigger/README.md`
|
||||
- Modify (out of repo): `/config/workspace/tiennm99/claude-code-routine-cron/README.md`
|
||||
- Create: `CHANGELOG.md` (Keep-A-Changelog format)
|
||||
- Optional: `.github/release.yml` for release-notes automation
|
||||
|
||||
## Implementation Steps
|
||||
1. Draft README per architecture sections — use sibling READMEs verbatim where applicable (Apache-2.0 license clauses, beta header note, security note about per-routine token scope).
|
||||
2. Write `CHANGELOG.md` with `## [0.1.0] - 2026-MM-DD` initial entry listing features.
|
||||
3. Local proof: deploy author's instance via `wrangler deploy`, attach a non-prod routine, observe one cron tick, capture `session_url` for README screenshot.
|
||||
4. Tag and release:
|
||||
```bash
|
||||
git tag v0.1.0
|
||||
git push origin v0.1.0
|
||||
gh release create v0.1.0 --generate-notes
|
||||
```
|
||||
5. Open PRs (or direct commits if author owns) to update sibling READMEs with the comparison row.
|
||||
6. Final commit: `docs: README + v0.1.0 release notes`.
|
||||
|
||||
## Success Criteria
|
||||
- [ ] README renders cleanly on GitHub
|
||||
- [ ] All code blocks in README are runnable (copy-paste tested)
|
||||
- [ ] Comparison table accurately reflects all 3 options
|
||||
- [ ] Both sibling READMEs reference this repo
|
||||
- [ ] `v0.1.0` tag pushed and GitHub release published
|
||||
- [ ] Author's own deployment fired at least one successful cron tick (proof-of-life)
|
||||
|
||||
## Risk Assessment
|
||||
- **Beta header drift:** if Anthropic ships new dated beta between phase 2 and release, bump in code + CHANGELOG before tagging.
|
||||
- **Sibling repo edits cause merge conflicts:** branch + PR if user has unmerged work; otherwise direct commit.
|
||||
- **CF dashboard URL format changes:** don't hardcode dashboard URLs in README; use `wrangler tail` (CLI) which is stable.
|
||||
|
||||
## Security Considerations
|
||||
- README must NOT include real `ROUTINE_FIRE_URL` or token — only `sk-ant-oat01-...` placeholder format.
|
||||
- Document that pushing to a public fork preserves `.dev.vars` exclusion (gitignore line).
|
||||
|
||||
## Next Steps (post v0.1.0)
|
||||
- v0.2: optional `WEBHOOK_URL` to mirror fire output to Slack/Discord (low priority — most users use `wrangler tail`).
|
||||
- v0.3: `wrangler.toml.example` with multi-routine deployment helper script.
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
title: claude-code-routine-trigger-worker — CF Workers cron port
|
||||
description: >-
|
||||
Cloudflare Workers port of claude-code-routine-cron: scheduled handler fires
|
||||
Claude Code routine /fire endpoint, zero infra, free tier
|
||||
status: completed
|
||||
completed: 2026-05-09
|
||||
priority: P2
|
||||
created: 2026-05-09T00:00:00.000Z
|
||||
target_repo: /config/workspace/tiennm99/claude-code-routine-trigger-worker
|
||||
reference_repos:
|
||||
- /config/workspace/tiennm99/claude-code-routine-trigger
|
||||
- /config/workspace/tiennm99/claude-code-routine-cron
|
||||
license: Apache-2.0
|
||||
blockedBy: []
|
||||
blocks: []
|
||||
---
|
||||
|
||||
# claude-code-routine-trigger-worker — CF Workers cron port
|
||||
|
||||
## Problem
|
||||
Two existing siblings cover the routine-fire space, both with trade-offs:
|
||||
- `claude-code-routine-trigger` (GH Actions): free, but cron unreliable (30 min – 2h delays, occasional drops at minute `0`).
|
||||
- `claude-code-routine-cron` (Go daemon, Docker): sub-second precision, but requires self-hosted infra.
|
||||
|
||||
Gap: a free, no-infra option with reliable timing.
|
||||
|
||||
## Goal
|
||||
Cloudflare Workers port: scheduled handler POSTs to the Claude Code routine `/fire` endpoint on a fixed cron. Free tier (CF Workers cron triggers cost $0 within free limits). Single TypeScript file, deploy via `wrangler deploy`.
|
||||
|
||||
## Locked Decisions
|
||||
| # | Decision | Choice |
|
||||
|---|----------|--------|
|
||||
| 1 | Repo name | Completed |
|
||||
| 2 | Scope | Completed |
|
||||
| 3 | Cron config | Completed |
|
||||
| 4 | Manual HTTP fire | Completed |
|
||||
| 5 | Language | Completed |
|
||||
| 6 | Secret management | `wrangler secret put` — `ROUTINE_FIRE_URL`, `ROUTINE_FIRE_TOKEN` |
|
||||
| 7 | Optional config | `TEXT_TEMPLATE` (env var, plain string with placeholders), `TZ` (env var) |
|
||||
| 8 | License | Apache-2.0 (matches siblings) |
|
||||
|
||||
## Non-goals
|
||||
- Manual `/fire` HTTP endpoint (KISS — scheduled only)
|
||||
- Multiple routines per worker (deploy N workers for N routines)
|
||||
- Retry on failure (each POST = new session; retries multiply sessions)
|
||||
- Durable Objects / KV / D1 (stateless)
|
||||
- Custom log shipping (CF dashboard `wrangler tail` is enough)
|
||||
- Go-style `text/template` (use simple `{token}` substitution — KISS)
|
||||
|
||||
## Comparison Matrix (post-implementation)
|
||||
| | trigger (GH Actions) | cron (Go daemon) | **trigger-worker (this)** |
|
||||
| ---------------- | -------------------- | ------------------------ | ------------------------- |
|
||||
| Runs on | GitHub runners | self-hosted Docker | Cloudflare edge |
|
||||
| Cost | free (GH minutes) | minimal (own infra) | free (CF free tier) |
|
||||
| Cron precision | ±30 min – 2 h | sub-second | ±15 sec (CF SLA) |
|
||||
| Setup | fork + 2 secrets | env vars + Docker | wrangler deploy + secrets |
|
||||
| Audit trail | Actions runs page | container stdout | CF dashboard / `tail` |
|
||||
|
||||
## Phases
|
||||
|
||||
| Phase | Name | Status |
|
||||
|-------|------|--------|
|
||||
| 1 | [Repo scaffold](./phase-01-repo-scaffold.md) | Completed |
|
||||
| 2 | [Worker handler + fire client](./phase-02-worker-handler-fire-client.md) | Completed |
|
||||
| 3 | [wrangler config + secrets](./phase-03-wrangler-config-secrets.md) | Completed |
|
||||
| 4 | [Tests + CI](./phase-04-tests-ci.md) | Completed |
|
||||
| 5 | [Docs + release](./phase-05-docs-release.md) | Completed |
|
||||
|
||||
## Dependencies
|
||||
None. Greenfield repo. Reference repos read-only.
|
||||
|
||||
## Risk Register
|
||||
| Risk | Severity | Mitigation |
|
||||
|---|---|---|
|
||||
| CF Workers cron precision in practice | Low | Documented ±15 sec — adequate for routine triggering |
|
||||
| Wrangler config drift with new CF features | Low | Pin wrangler major version in `package.json` |
|
||||
| Beta header (`experimental-cc-routine-2026-04-01`) churn | Medium | Constant in code; bump via release; siblings have same exposure |
|
||||
| Free tier cron limit (5 unique cron expressions per worker) | Low | One-routine-per-worker model means single-digit crons typical |
|
||||
| Secrets accidentally committed | High | `.dev.vars` gitignored; secrets only via `wrangler secret put` in CI |
|
||||
|
||||
## Success Criteria
|
||||
- [ ] `wrangler deploy` ships worker; first cron tick fires `/fire` and logs `claude_code_session_url`.
|
||||
- [ ] `wrangler tail` shows JSON-structured logs for each fire.
|
||||
- [ ] All CF Workers cron expressions in `wrangler.toml` validated by `wrangler dev` locally.
|
||||
- [ ] CI runs typecheck + tests on push.
|
||||
- [ ] README documents quickstart, env vars, secret rotation, beta header policy.
|
||||
- [ ] Release `v0.1.0` published with `wrangler.toml` example.
|
||||
|
||||
## References
|
||||
- Sibling Go daemon: `/config/workspace/tiennm99/claude-code-routine-cron`
|
||||
- Sibling GH Actions: `/config/workspace/tiennm99/claude-code-routine-trigger`
|
||||
- Anthropic `/fire` API: https://platform.claude.com/docs/en/api/claude-code/routines-fire
|
||||
- CF Workers cron: https://developers.cloudflare.com/workers/configuration/cron-triggers/
|
||||
@@ -0,0 +1,10 @@
|
||||
import { cloudflareTest } from '@cloudflare/vitest-pool-workers';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
cloudflareTest({
|
||||
wrangler: { configPath: './wrangler.toml' },
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* @typedef {object} Env
|
||||
* @property {string} ROUTINE_FIRE_URL - secret: Anthropic /fire endpoint URL
|
||||
* @property {string} ROUTINE_FIRE_TOKEN - secret: per-routine bearer token (sk-ant-oat01-...)
|
||||
* @property {string} [TEXT_TEMPLATE] - plain var: prompt template, supports {LocalTime} {Cron} {ISO}
|
||||
* @property {string} [TZ] - plain var: IANA tz, default 'UTC'
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} FireResponse
|
||||
* @property {string} type
|
||||
* @property {string} claude_code_session_id
|
||||
* @property {string} claude_code_session_url
|
||||
*/
|
||||
|
||||
const HEADER_VERSION = '2023-06-01';
|
||||
const HEADER_BETA = 'experimental-cc-routine-2026-04-01';
|
||||
const DEFAULT_TEMPLATE = 'Scheduled trigger at {LocalTime}';
|
||||
const DEFAULT_TZ = 'UTC';
|
||||
|
||||
/**
|
||||
* Substitutes `{Key}` tokens in template. Unknown tokens left intact and warn-logged.
|
||||
* @param {string} template
|
||||
* @param {Record<string, string>} vars
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderText(template, vars) {
|
||||
return template.replace(/\{(\w+)\}/g, (match, key) => {
|
||||
if (Object.prototype.hasOwnProperty.call(vars, key)) {
|
||||
return vars[key];
|
||||
}
|
||||
return match;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a Date as `YYYY-MM-DD HH:mm ZZZ` in the given IANA timezone.
|
||||
* @param {Date} date
|
||||
* @param {string} tz
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatLocalTime(date, tz) {
|
||||
const fmt = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: tz,
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', hour12: false,
|
||||
timeZoneName: 'short',
|
||||
});
|
||||
const parts = Object.fromEntries(fmt.formatToParts(date).map(p => [p.type, p.value]));
|
||||
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute} ${parts.timeZoneName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* POSTs to the Claude Code routine /fire endpoint. Logs structured JSON.
|
||||
* Never throws — network/HTTP failures are logged at error level so the
|
||||
* Worker doesn't crash and CF doesn't retry (each /fire = new session).
|
||||
*
|
||||
* @param {Env} env
|
||||
* @param {{ cron: string, scheduledTime: number }} controller
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function fireRoutine(env, controller) {
|
||||
const cron = controller.cron;
|
||||
const tz = env.TZ ?? DEFAULT_TZ;
|
||||
const template = env.TEXT_TEMPLATE ?? DEFAULT_TEMPLATE;
|
||||
const now = new Date(controller.scheduledTime);
|
||||
|
||||
const text = renderText(template, {
|
||||
ISO: now.toISOString(),
|
||||
LocalTime: formatLocalTime(now, tz),
|
||||
Cron: cron,
|
||||
});
|
||||
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(env.ROUTINE_FIRE_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${env.ROUTINE_FIRE_TOKEN}`,
|
||||
'anthropic-version': HEADER_VERSION,
|
||||
'anthropic-beta': HEADER_BETA,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(JSON.stringify({
|
||||
level: 'error', msg: 'fire request failed', cron,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const respBody = await resp.text();
|
||||
if (!resp.ok) {
|
||||
console.log(JSON.stringify({
|
||||
level: 'error', msg: 'fire non-2xx response',
|
||||
cron, status: resp.status, body: respBody,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
/** @type {FireResponse | null} */
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = JSON.parse(respBody);
|
||||
} catch {
|
||||
console.log(JSON.stringify({
|
||||
level: 'warn', msg: 'fire 2xx but body unparseable',
|
||||
cron, status: resp.status, body: respBody,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
level: 'info', msg: 'fire ok', cron,
|
||||
session_url: parsed?.claude_code_session_url,
|
||||
session_id: parsed?.claude_code_session_id,
|
||||
}));
|
||||
}
|
||||
|
||||
/** @type {ExportedHandler<Env>} */
|
||||
export default {
|
||||
async scheduled(controller, env, ctx) {
|
||||
ctx.waitUntil(fireRoutine(env, controller));
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import worker, { renderText, formatLocalTime, fireRoutine } from './worker.js';
|
||||
|
||||
const TEST_TOKEN = 'sk-ant-oat01-test-only-not-real';
|
||||
const TEST_URL = 'https://api.anthropic.com/v1/claude_code/routines/trig_TEST/fire';
|
||||
|
||||
/**
|
||||
* Captures `console.log` output for assertion. Restores on cleanup.
|
||||
* @returns {{ logs: string[], restore: () => void }}
|
||||
*/
|
||||
function captureLogs() {
|
||||
const logs = [];
|
||||
const original = console.log;
|
||||
console.log = (msg) => { logs.push(typeof msg === 'string' ? msg : JSON.stringify(msg)); };
|
||||
return { logs, restore: () => { console.log = original; } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a minimal Env for tests.
|
||||
* @param {Partial<import('./worker.js').Env>} overrides
|
||||
* @returns {import('./worker.js').Env}
|
||||
*/
|
||||
function buildEnv(overrides = {}) {
|
||||
return {
|
||||
ROUTINE_FIRE_URL: TEST_URL,
|
||||
ROUTINE_FIRE_TOKEN: TEST_TOKEN,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const fixedTime = Date.UTC(2026, 4, 9, 3, 19, 0); // 2026-05-09T03:19:00Z (cron 19 3 * * *)
|
||||
const baseController = { cron: '19 3 * * *', scheduledTime: fixedTime };
|
||||
|
||||
describe('renderText', () => {
|
||||
it('substitutes {LocalTime}, {Cron}, {ISO}', () => {
|
||||
const out = renderText('At {LocalTime} cron {Cron} iso {ISO}', {
|
||||
LocalTime: '2026-05-09 10:19 GMT+7',
|
||||
Cron: '19 3 * * *',
|
||||
ISO: '2026-05-09T03:19:00.000Z',
|
||||
});
|
||||
expect(out).toBe('At 2026-05-09 10:19 GMT+7 cron 19 3 * * * iso 2026-05-09T03:19:00.000Z');
|
||||
});
|
||||
|
||||
it('leaves unknown {Foo} tokens intact', () => {
|
||||
const out = renderText('hello {Foo}', { Bar: 'baz' });
|
||||
expect(out).toBe('hello {Foo}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatLocalTime', () => {
|
||||
it('defaults to UTC formatting', () => {
|
||||
const s = formatLocalTime(new Date(fixedTime), 'UTC');
|
||||
expect(s).toMatch(/^2026-05-09 03:19 /);
|
||||
});
|
||||
|
||||
it('respects IANA tz', () => {
|
||||
const s = formatLocalTime(new Date(fixedTime), 'Asia/Ho_Chi_Minh');
|
||||
expect(s).toMatch(/^2026-05-09 10:19 /);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fireRoutine', () => {
|
||||
/** @type {ReturnType<typeof captureLogs>} */
|
||||
let cap;
|
||||
/** @type {ReturnType<typeof vi.fn>} */
|
||||
let fetchSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
cap = captureLogs();
|
||||
fetchSpy = vi.fn();
|
||||
globalThis.fetch = fetchSpy;
|
||||
});
|
||||
|
||||
function tearDown() {
|
||||
cap.restore();
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
|
||||
it('logs session_url on 2xx', async () => {
|
||||
fetchSpy.mockResolvedValue(new Response(JSON.stringify({
|
||||
type: 'ok',
|
||||
claude_code_session_id: 'sess_1',
|
||||
claude_code_session_url: 'https://claude.ai/s/sess_1',
|
||||
}), { status: 200 }));
|
||||
|
||||
await fireRoutine(buildEnv(), baseController);
|
||||
|
||||
const joined = cap.logs.join('\n');
|
||||
expect(joined).toContain('"level":"info"');
|
||||
expect(joined).toContain('https://claude.ai/s/sess_1');
|
||||
expect(joined).not.toContain(TEST_TOKEN);
|
||||
tearDown();
|
||||
});
|
||||
|
||||
it('logs level:error on 401 non-2xx', async () => {
|
||||
fetchSpy.mockResolvedValue(new Response('unauthorized', { status: 401 }));
|
||||
|
||||
await fireRoutine(buildEnv(), baseController);
|
||||
|
||||
const joined = cap.logs.join('\n');
|
||||
expect(joined).toContain('"level":"error"');
|
||||
expect(joined).toContain('"status":401');
|
||||
expect(joined).not.toContain(TEST_TOKEN);
|
||||
tearDown();
|
||||
});
|
||||
|
||||
it('logs error and does not throw when fetch rejects', async () => {
|
||||
fetchSpy.mockRejectedValue(new Error('network down'));
|
||||
|
||||
await expect(fireRoutine(buildEnv(), baseController)).resolves.toBeUndefined();
|
||||
const joined = cap.logs.join('\n');
|
||||
expect(joined).toContain('"level":"error"');
|
||||
expect(joined).toContain('network down');
|
||||
expect(joined).not.toContain(TEST_TOKEN);
|
||||
tearDown();
|
||||
});
|
||||
|
||||
it('sends required headers and JSON body', async () => {
|
||||
fetchSpy.mockResolvedValue(new Response(JSON.stringify({
|
||||
type: 'ok', claude_code_session_id: 's', claude_code_session_url: 'u',
|
||||
}), { status: 200 }));
|
||||
|
||||
await fireRoutine(buildEnv(), baseController);
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchSpy.mock.calls[0];
|
||||
expect(url).toBe(TEST_URL);
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.headers['Authorization']).toBe(`Bearer ${TEST_TOKEN}`);
|
||||
expect(init.headers['anthropic-version']).toBe('2023-06-01');
|
||||
expect(init.headers['anthropic-beta']).toBe('experimental-cc-routine-2026-04-01');
|
||||
expect(init.headers['Content-Type']).toBe('application/json');
|
||||
|
||||
const parsed = JSON.parse(init.body);
|
||||
expect(parsed).toHaveProperty('text');
|
||||
expect(typeof parsed.text).toBe('string');
|
||||
tearDown();
|
||||
});
|
||||
|
||||
it('uses default template when TEXT_TEMPLATE unset', async () => {
|
||||
fetchSpy.mockResolvedValue(new Response(JSON.stringify({
|
||||
type: 'ok', claude_code_session_id: 's', claude_code_session_url: 'u',
|
||||
}), { status: 200 }));
|
||||
|
||||
await fireRoutine(buildEnv(), baseController);
|
||||
|
||||
const body = JSON.parse(fetchSpy.mock.calls[0][1].body);
|
||||
expect(body.text).toMatch(/^Scheduled trigger at /);
|
||||
tearDown();
|
||||
});
|
||||
|
||||
it('defaults TZ to UTC when env.TZ unset', async () => {
|
||||
fetchSpy.mockResolvedValue(new Response(JSON.stringify({
|
||||
type: 'ok', claude_code_session_id: 's', claude_code_session_url: 'u',
|
||||
}), { status: 200 }));
|
||||
|
||||
await fireRoutine(buildEnv(), baseController);
|
||||
|
||||
const body = JSON.parse(fetchSpy.mock.calls[0][1].body);
|
||||
expect(body.text).toContain('03:19'); // UTC hour from fixedTime
|
||||
tearDown();
|
||||
});
|
||||
|
||||
it('warn-logs unparseable 2xx body', async () => {
|
||||
fetchSpy.mockResolvedValue(new Response('not-json', { status: 200 }));
|
||||
|
||||
await fireRoutine(buildEnv(), baseController);
|
||||
|
||||
const joined = cap.logs.join('\n');
|
||||
expect(joined).toContain('"level":"warn"');
|
||||
expect(joined).toContain('unparseable');
|
||||
tearDown();
|
||||
});
|
||||
});
|
||||
|
||||
describe('default export (scheduled handler)', () => {
|
||||
it('exposes async scheduled function', () => {
|
||||
expect(typeof worker.scheduled).toBe('function');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
name = "claude-code-routine-trigger-worker"
|
||||
main = "worker.js"
|
||||
compatibility_date = "2026-05-09"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
|
||||
# Plain (non-secret) vars. Override per-environment in the dashboard or via
|
||||
# `wrangler.toml` env blocks. Secrets go through `wrangler secret put`.
|
||||
[vars]
|
||||
TEXT_TEMPLATE = "Scheduled trigger at {LocalTime}"
|
||||
TZ = "Asia/Ho_Chi_Minh"
|
||||
|
||||
# Cron triggers fire from this list. CF Workers requires literal cron
|
||||
# expressions here — they cannot be loaded from env vars or secrets.
|
||||
#
|
||||
# DISABLED: this worker is parked. I migrated this routine to cron-job.org
|
||||
# to keep my CF cron-trigger quota free for other workers (see README).
|
||||
#
|
||||
# `crons = []` (empty array) is REQUIRED to clear schedules from CF — just
|
||||
# omitting/commenting the block leaves stale entries in the dashboard,
|
||||
# because Wrangler only pushes what you declare.
|
||||
#
|
||||
# To re-enable: replace the empty array with your schedule (sample below)
|
||||
# and `npx wrangler deploy`.
|
||||
# crons = [
|
||||
# "0 0-17,22-23 * * *", # UTC+7: 00:00 + 05:00..23:00 hourly
|
||||
# ]
|
||||
[triggers]
|
||||
crons = []
|
||||
|
||||
# Workers Logs / traces. `head_sampling_rate = 1` keeps 100% of invocations
|
||||
# (low volume here — ~20/day — so full retention is free). Drop to e.g. 0.1
|
||||
# if traffic ever grows enough to matter.
|
||||
[observability]
|
||||
enabled = true
|
||||
head_sampling_rate = 1
|
||||
|
||||
[observability.logs]
|
||||
enabled = true
|
||||
head_sampling_rate = 1
|
||||
invocation_logs = true
|
||||
@@ -9,7 +9,7 @@ Self-hosted Go daemon that fires a [Claude Code routine](https://code.claude.com
|
||||
|
||||
## Why this vs `claude-code-routine-trigger`
|
||||
|
||||
| | [claude-code-routine-trigger](https://github.com/tiennm99/claude-code-routine-trigger) | **claude-code-routine-cron** (this repo) |
|
||||
| | [GitHub Actions](../) (repo root) | **self-hosted** (this directory) |
|
||||
| ---------------- | -------------------------------------------------------------------------------------- | ---------------------------------------- |
|
||||
| Runs on | GitHub Actions runners | your infra (Docker host, k8s, NAS, RPi) |
|
||||
| Cost | free (within GitHub minutes) | minimal (your infra) |
|
||||
@@ -113,10 +113,10 @@ The request pins `anthropic-beta: experimental-cc-routine-2026-04-01`. When Anth
|
||||
## Build from source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/tiennm99/claude-code-routine-cron
|
||||
cd claude-code-routine-cron
|
||||
git clone https://github.com/tiennm99/claude-code-routine-trigger
|
||||
cd claude-code-routine-trigger/self-hosted
|
||||
go build .
|
||||
./claude-code-routine-cron # uses env vars from your shell
|
||||
./self-hosted # uses env vars from your shell
|
||||
```
|
||||
|
||||
Tests:
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
module github.com/tiennm99/claude-code-routine-cron
|
||||
module github.com/tiennm99/claude-code-routine-trigger/self-hosted
|
||||
|
||||
go 1.22
|
||||
|
||||
|
||||
Reference in new issue
Block a user