{"id":51493912,"url":"https://github.com/kiritocode1/chronos","last_synced_at":"2026-07-07T13:01:44.178Z","repository":{"id":361539956,"uuid":"1243644830","full_name":"kiritocode1/chronos","owner":"kiritocode1","description":null,"archived":false,"fork":false,"pushed_at":"2026-05-31T04:55:00.000Z","size":603,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-31T06:19:23.977Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/kiritocode1.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":"AGENTS.md","dco":null,"cla":null}},"created_at":"2026-05-19T14:28:17.000Z","updated_at":"2026-05-31T04:55:03.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/kiritocode1/chronos","commit_stats":null,"previous_names":["kiritocode1/chronos"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/kiritocode1/chronos","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/kiritocode1%2Fchronos","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/kiritocode1%2Fchronos/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/kiritocode1%2Fchronos/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/kiritocode1%2Fchronos/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/kiritocode1","download_url":"https://codeload.github.com/kiritocode1/chronos/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/kiritocode1%2Fchronos/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35228636,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-07T02:00:07.222Z","response_time":90,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-07-07T13:01:42.610Z","updated_at":"2026-07-07T13:01:43.827Z","avatar_url":"https://github.com/kiritocode1.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Chronos\n\n![chronos](./public/intro.png)\n\nA distributed job scheduler. Submit one-time or recurring jobs that fire as **webhook calls** or **sandboxed bash scripts**, with durable execution, automatic retries, full per-run logs, and in-app notifications when retries are exhausted.\n\nBuilt for the Airtribe Backend Engineering Launchpad case study.\n\n\u003e **Docs map**\n\u003e [`PRD.md`](./PRD.md) for the original design doc (data model, decisions log).\n\u003e [`DISTRIBUTED.md`](./DISTRIBUTED.md) for the operational story (multi-node coordination, shard locks, failure modes).\n\u003e [`CASE_STUDY.md`](./CASE_STUDY.md) for the case-study response (architecture, trade-offs, what makes Chronos special).\n\n## Stack\n\n| Layer | Choice |\n|---|---|\n| Runtime | Bun |\n| Language | TypeScript (strict) |\n| Effect system | `effect@3.x` (typed errors, layered DI, schedules for retry) |\n| Durable workflows | `@effect/workflow` + `@effect/cluster` (sharded multi-runner cluster on Postgres, with singleton election) |\n| DB | Postgres 16 (Docker) |\n| ORM | Drizzle ORM `1.0-beta` via `drizzle-orm/effect-postgres` adapter |\n| HTTP server | `@effect/platform-bun` HttpServer |\n| Inter-node RPC | msgpack over TCP, via `@effect/platform-bun/BunClusterSocket` |\n| Auth | Better Auth (email + password) |\n| Sandbox (bash mode) | `just-bash` (in-process virtual filesystem with network allowlist) |\n| Cron parser | `croner` |\n| Frontend | Vite + React 19 + TypeScript + Tailwind v4 + TanStack Query + React Router + shadcn (`@jalco` registry components) |\n\n## Quick start\n\nPrerequisites: **Bun ≥ 1.3**, **Docker Desktop**.\n\n```bash\n# 1. install deps\nbun install\ncd web \u0026\u0026 bun install \u0026\u0026 cd ..\n\n# 2. start Postgres\nbun run db:up\n\n# 3. apply our domain schema (jobs/job_runs/notifications)\nbun run db:push\n\n# 4. apply Better Auth schema (user/session/account/verification)\nbunx @better-auth/cli@latest migrate --config src/auth/better-auth.ts --yes\n\n# 5. run the backend\nbun run dev               # http://localhost:3000\n\n# 6. (separate terminal) run the dashboard\ncd web \u0026\u0026 bun dev         # http://localhost:5173, Vite proxies /api/* to :3000\n```\n\nDefault Postgres credentials live in `.env.example`. Copy to `.env`:\n\n```bash\ncp .env.example .env\n# then generate a session secret:\nopenssl rand -base64 32  # paste into BETTER_AUTH_SECRET\n```\n\nThe `@effect/cluster` workflow storage tables (`cluster_messages`, `cluster_runners`, etc.) self-bootstrap on first server boot. No manual step needed.\n\n## Running multiple nodes\n\nEvery Chronos process is identical. Point N processes at the same Postgres, give each a unique `RUNNER_PORT`, and they register as a cluster automatically. Two nodes on one machine:\n\n```bash\nPORT=3000 RUNNER_PORT=34430 bun run start\nPORT=3001 RUNNER_PORT=34431 bun run start\n```\n\nOnly one node runs the ticker (singleton election via Postgres advisory lock). Workflow execution is sharded across all healthy runners. Kill any node and its shards reassign to survivors inside the lock expiration window. For production, set `RUNNER_HOST` on each node to the address peers can route to, and put the HTTP API behind a load balancer. See [`DISTRIBUTED.md`](./DISTRIBUTED.md) for partition behavior, shard groups, and the full env-var contract.\n\n## Architecture\n\nChronos is an actor system. Every running job is a workflow entity inside an `@effect/cluster` shard. A **ticker singleton** (one node, elected via Postgres advisory lock) polls every second for jobs whose `next_run_at \u003c= now()`, claims them with `SELECT ... FOR UPDATE SKIP LOCKED`, and dispatches each as a **workflow execution** routed by shard id to whichever node owns the shard. The workflow engine persists each activity's intent to Postgres before running it, so a process crash resumes from the last completed step on whichever runner takes over the shard. The activity itself runs the work (HTTP fetch or `just-bash` exec), retries internally with exponential backoff and jitter, and on terminal failure inserts a `notifications` row that the UI polls every 30 seconds.\n\nThe conceptual framing (workflow as actor, intent log, single-authority coordination) is written up in [Rethinking Backend Architecture: A Requiem of Processes and Data](https://blog.aryank.space/articles/rethinking-backend-architecture-for-a-requiem-of-processes-and-data).\n\n### System view\n\n```mermaid\nflowchart LR\n    Client[Dashboard / curl]\n    LB[Load Balancer]\n    subgraph Cluster[\"Chronos cluster (N identical nodes)\"]\n      direction TB\n      A[Node A\u003cbr/\u003eHTTP + Sharding + Ticker]\n      B[Node B\u003cbr/\u003eHTTP + Sharding]\n      C[Node C\u003cbr/\u003eHTTP + Sharding]\n      A \u003c--\u003e|msgpack RPC| B\n      B \u003c--\u003e|msgpack RPC| C\n      A \u003c--\u003e|msgpack RPC| C\n    end\n    PG[(\"Postgres\u003cbr/\u003ejobs · job_runs · notifications\u003cbr/\u003ecluster_runners · cluster_messages\")]\n    Client --\u003e|HTTPS| LB\n    LB --\u003e A\n    LB --\u003e B\n    LB --\u003e C\n    A --\u003e|claim · lease · persist| PG\n    B --\u003e|claim · lease · persist| PG\n    C --\u003e|claim · lease · persist| PG\n```\n\n### Job lifecycle\n\n```mermaid\nsequenceDiagram\n    actor User\n    participant API as HTTP API\n    participant Ticker as Ticker singleton\n    participant DB as Postgres\n    participant Shard as Workflow runner\u003cbr/\u003e(shard owner)\n    User-\u003e\u003eAPI: POST /api/jobs (cron or runAt)\n    API-\u003e\u003eDB: insert jobs row, set next_run_at\n    loop every 1s\n      Ticker-\u003e\u003eDB: SELECT ... FOR UPDATE SKIP LOCKED\n      Ticker-\u003e\u003eDB: advance next_run_at (cron) or mark completed\n      Ticker-\u003e\u003eShard: dispatch workflow(executionId)\n    end\n    Shard-\u003e\u003eDB: persist activity intent\n    Shard-\u003e\u003eShard: run bash / webhook\n    alt success\n      Shard-\u003e\u003eDB: write job_runs row (succeeded)\n    else failure after retries\n      Shard-\u003e\u003eDB: write job_runs row (failed)\n      Shard-\u003e\u003eDB: insert notifications row\n    end\n```\n\n## API at a glance\n\nAll `/api/jobs/*`, `/api/runs/*`, `/api/notifications/*` routes require a Better Auth session cookie. The dashboard's **/api-ref** page renders these tables interactively with type-coloured props (powered by the `@jalco/api-ref-table` component).\n\n| Method | Path | Purpose |\n|---|---|---|\n| ALL | `/api/auth/*` | Better Auth (sign-up, sign-in, sign-out, session) |\n| POST | `/api/jobs` | Create job |\n| GET | `/api/jobs` | List user's jobs (paginated) |\n| GET | `/api/jobs/:id` | Job detail |\n| PATCH | `/api/jobs/:id` | Edit (name, payload, schedule, retry policy, pause/active) |\n| DELETE | `/api/jobs/:id` | Cancel and delete (cascades to runs and notifications) |\n| POST | `/api/jobs/:id/run` | Manually trigger now |\n| GET | `/api/jobs/:id/runs` | Run history |\n| GET | `/api/runs/:id` | Run detail with full logs |\n| GET | `/api/notifications` | List notifications, `?unseenOnly=true` filter |\n| GET | `/api/notifications/unseen-count` | For the UI badge |\n| POST | `/api/notifications/:id/seen` | Mark as read |\n\n### Request shapes\n\n**Create a one-time webhook job:**\n\n```bash\ncurl -X POST http://localhost:3000/api/jobs \\\n  -H \"Content-Type: application/json\" -b cookies.txt \\\n  -d '{\n    \"name\": \"ping httpbin\",\n    \"payload\": {\n      \"kind\": \"webhook\",\n      \"url\": \"https://httpbin.org/get\",\n      \"method\": \"GET\"\n    },\n    \"schedule\": { \"runAt\": \"2026-05-20T14:00:00Z\" }\n  }'\n```\n\n**Create a recurring bash job:**\n\n```bash\ncurl -X POST http://localhost:3000/api/jobs \\\n  -H \"Content-Type: application/json\" -b cookies.txt \\\n  -d '{\n    \"name\": \"daily report\",\n    \"payload\": {\n      \"kind\": \"bash\",\n      \"script\": \"echo hello | tr a-z A-Z\",\n      \"timeoutMs\": 30000,\n      \"allowedUrls\": []\n    },\n    \"schedule\": { \"cron\": \"0 9 * * *\" },\n    \"retryPolicy\": { \"maxAttempts\": 3, \"baseMs\": 1000, \"maxMs\": 60000, \"jitter\": true }\n  }'\n```\n\n## Key design decisions\n\nThese are the calls worth flagging in a code review. Deeper reasoning lives in [`PRD.md`](./PRD.md) §12 and the architectural framing in [`CASE_STUDY.md`](./CASE_STUDY.md).\n\n1. **Effect Workflow as the durable execution primitive, not a hand-rolled retry loop.** Retries, state survival across crashes, and idempotency are exactly what workflow engines exist to solve. Building them by hand would reimplement well-trodden ground. The actor framing makes the math of crash recovery a property of the framework, not the application.\n2. **Job-row plus workflow-per-execution model, NOT one long-lived looping workflow per job.** The case study describes a *scheduler* (users manage jobs, view runs, reschedule). A `jobs` row driving a per-tick workflow maps cleanly to that user-facing model. Cleaner cancel and reschedule semantics.\n3. **Sharded multi-runner cluster on Postgres, with singleton election for the ticker.** Multiple nodes register in `cluster_runners`, take shard ownership via Postgres advisory locks, and route workflow messages to each other over msgpack RPC. The ticker is wrapped in `Singleton.make` so exactly one node ticks. `SELECT ... FOR UPDATE SKIP LOCKED` stays in the claim path as belt-and-suspenders during failover windows.\n4. **`just-bash` for shell jobs, not Docker-per-job.** In-process virtual FS, no container spin-up cost (about 100 ms saved per execution), no Docker daemon dependency, network defaults off with per-job allowlist. Real isolation without extra ops surface. For dedicated bash workers, opt into a shard group so bash execution lands only on nodes provisioned for it.\n5. **Better Auth, not hand-rolled JWT.** Owns its own tables in Postgres, plays with Bun, sessions plus cookies plus CSRF handled out of the box. About 20 lines of glue to wrap into an Effect `Auth.requireUser` service.\n6. **Drizzle ORM 1.0-beta via `effect-postgres`, layered on top of `@effect/sql-pg`.** Type-safe queries (refactor safety, IDE autocomplete), shared connection pool, `drizzle-kit push` for schema sync. Beta channel, because the adapter only exists on 1.0-beta.\n\n## What's not built (intentional)\n\n- Email and SMS notifications (in-app DB-table notifications only).\n- OAuth, magic links, passkeys, 2FA.\n- Per-attempt run rows. Retries happen internally inside the workflow, and one `job_runs` row per workflow execution captures the final outcome.\n- Multi-tenant orgs and role-based auth. Per-user scoping only.\n- Job dependencies, DAGs, fan-out.\n- Bash per-job isolation (Firecracker, gVisor, per-job container). Current sandboxing is `just-bash` virtual FS plus egress allowlist. Multi-node bash execution is supported via shard groups, but per-job isolation lives on the roadmap, not in the code today.\n\n## Operating notes and gotchas\n\n- **Don't use `bun --hot` with this app.** `@effect/cluster`'s message processor captures references during layer construction, so partial reloads leave the cluster engine in an inconsistent state where workflows register but no live worker picks them up. The `dev` script uses `bun --watch` (full restart) for that reason.\n- **Cluster owns its schema.** Don't try to manage `cluster_*` tables with `drizzle-kit`. The `tablesFilter` in `drizzle.config.ts` scopes drizzle-kit to our owned tables only (`jobs`, `job_runs`, `notifications`).\n- **Better Auth runs its config under `process.env`, not `Bun.env`.** Their CLI uses jiti (Node-based) to load the config file, and using `Bun.env` would crash the migrate command.\n- **just-bash sandboxing.** Bash jobs run in an in-process virtual filesystem with **no network access by default**. To allow specific URLs, pass `allowedUrls: [\"https://api.example.com\"]` in the bash payload. For production multi-tenant, add SSRF blocking for private IP ranges plus AWS metadata endpoint.\n- **just-bash execution limits.** The Bash sandbox has runtime safety caps to prevent runaway compute. We raise them above the library defaults (10k commands / loop iterations) to 1,000,000 each, tunable via `BASH_MAX_COMMANDS` and `BASH_MAX_LOOPS` env vars. If you see `bash: too many commands executed (\u003eX)`, bump those.\n- **`RUNNER_PORT` must be unique per process on a shared host.** Two nodes booting with the same runner port will fail `bind()` on the second one. Loud and immediate, not silent corruption.\n\n## Repository layout\n\n```\nchronos/\n├── docker-compose.yml          # Postgres 16\n├── drizzle.config.ts           # drizzle-kit config (tablesFilter scoped)\n├── src/\n│   ├── main.ts                 # entrypoint: HTTP server, ticker singleton, cluster runtime\n│   ├── db/\n│   │   ├── client.ts           # PgClient + Drizzle Db layer\n│   │   └── schema.ts           # Drizzle schema\n│   ├── auth/                   # Better Auth config + Effect Auth service\n│   ├── jobs/                   # CRUD HTTP routes, repo, Effect Schema\n│   ├── runs/                   # read routes + repo + Schema\n│   ├── notifications/          # read routes + repo + Schema\n│   ├── ticker/                 # claim loop (FOR UPDATE SKIP LOCKED), wrapped in Singleton\n│   └── workflows/              # WebhookJob + BashJob + dispatch\n├── web/                        # Vite + React + Tailwind + shadcn dashboard\n├── scripts/                    # smoke.sh end-to-end check\n├── PRD.md                      # original design doc\n├── DISTRIBUTED.md              # operational story for multi-node\n└── CASE_STUDY.md               # case-study response\n```\n\n## Scripts\n\n```bash\n# backend\nbun run dev          # bun --watch src/main.ts (auto-restart on change)\nbun run start        # plain bun src/main.ts\nbun run typecheck    # tsc --noEmit\n\n# database\nbun run db:up        # docker compose up -d postgres\nbun run db:down\nbun run db:logs\nbun run db:push      # drizzle-kit push (sync schema)\nbun run db:generate  # drizzle-kit generate (create versioned migration)\nbun run db:studio    # drizzle-kit studio (DB GUI)\n\n# frontend\ncd web\nbun dev              # Vite dev server with /api proxy\nbun run build        # tsc -b \u0026\u0026 vite build\n```\n\n## License\n\nMIT\n\n## Submissions by [Aryan Kathawale](https:tldr.aryank.space)\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fkiritocode1%2Fchronos","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fkiritocode1%2Fchronos","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fkiritocode1%2Fchronos/lists"}