Compare commits
1 Commits
docs/gatew
...
feat/mosai
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ee48e7681 |
@@ -1,193 +0,0 @@
|
||||
# Gateway Admin Token Recovery — Implementation Plan
|
||||
|
||||
**Mission:** `cli-unification-20260404`
|
||||
**Task:** `CU-03-01` (planning only — no runtime code changes)
|
||||
**Status:** Design locked (Session 1) — BetterAuth cookie-based recovery
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
The gateway installer strands operators when the admin user exists but the admin
|
||||
API token is missing. Concrete trigger:
|
||||
|
||||
- `~/.config/mosaic/gateway/meta.json` was deleted / regenerated.
|
||||
- The installer was re-run after a previous successful bootstrap.
|
||||
|
||||
Flow today (`packages/mosaic/src/commands/gateway/install.ts:375-400`):
|
||||
|
||||
1. `bootstrapFirstUser` hits `GET /api/bootstrap/status`.
|
||||
2. Server returns `needsSetup: false` because `users` count > 0.
|
||||
3. Installer logs `Admin user already exists — skipping setup. (No admin token on file — sign in via the web UI to manage tokens.)` and returns.
|
||||
4. The operator now has:
|
||||
- No token in `meta.json`.
|
||||
- No CLI path to mint a new one (`mosaic gateway <anything>` that needs the token fails).
|
||||
- `POST /api/bootstrap/setup` locked out — it only runs when `users` count is zero (`apps/gateway/src/admin/bootstrap.controller.ts:34-37`).
|
||||
- `POST /api/admin/tokens` gated by `AdminGuard` — requires either a bearer token (which they don't have) or a BetterAuth session (which they don't have in the CLI).
|
||||
|
||||
Dead end. The web UI is the only escape hatch today, and for headless installs even that may be inaccessible.
|
||||
|
||||
## 2. Design Summary
|
||||
|
||||
The BetterAuth session cookie is the authority. The operator runs
|
||||
`mosaic gateway login` to sign in with email/password, which persists a session
|
||||
cookie via `saveSession` (reusing `packages/mosaic/src/auth.ts`). With a valid
|
||||
session, `mosaic gateway config recover-token` (stranded-operator entry point)
|
||||
and `mosaic gateway config rotate-token` call the existing authenticated admin
|
||||
endpoint `POST /api/admin/tokens` using the cookie, then persist the returned
|
||||
plaintext to `meta.json` via `writeMeta`. **No new server endpoints are
|
||||
required** — `AdminGuard` already accepts BetterAuth session cookies via its
|
||||
`validateSession` path (`apps/gateway/src/admin/admin.guard.ts:90-120`).
|
||||
|
||||
## 3. Surface Contract
|
||||
|
||||
### 3.1 Server — no changes required
|
||||
|
||||
| Endpoint | Status | Notes |
|
||||
| ------------------------------ | --------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `POST /api/admin/tokens` | **Reuse as-is** | `admin-tokens.controller.ts:46-72`. Returns `{ id, label, scope, expiresAt, lastUsedAt, createdAt, plaintext }`. |
|
||||
| `GET /api/admin/tokens` | **Reuse** | Useful for `mosaic gateway config tokens list` follow-on (out of scope for CU-03-01, but trivial once auth path exists). |
|
||||
| `DELETE /api/admin/tokens/:id` | **Reuse** | Used by rotate flow for optional old-token revocation. |
|
||||
| `POST /api/bootstrap/setup` | **Unchanged** | Remains first-user-only; not part of recovery. |
|
||||
|
||||
`AdminGuard.validateSession` takes BetterAuth cookies from `request.raw.headers`
|
||||
via `fromNodeHeaders` and calls `auth.api.getSession({ headers })`. It also
|
||||
enforces `role === 'admin'`. This is exactly the path the CLI will hit with
|
||||
`Cookie: better-auth.session_token=...`.
|
||||
|
||||
**Confirmed feasible** during CU-03-01 investigation.
|
||||
|
||||
### 3.2 `mosaic gateway login`
|
||||
|
||||
Thin wrapper over the existing top-level `mosaic login`
|
||||
(`packages/mosaic/src/cli.ts:42-76`) with gateway-specific defaults pulled from
|
||||
`readMeta()`.
|
||||
|
||||
| Aspect | Behavior |
|
||||
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Default gateway URL | `http://${meta.host}:${meta.port}` from `readMeta()`, fallback `http://localhost:14242`. |
|
||||
| Flow | Prompt email + password -> `signIn()` -> `saveSession()`. |
|
||||
| Persistence | `~/.mosaic/session.json` via existing `saveSession` (7-day expiry). |
|
||||
| Decision | **Thin wrapper**, not alias. Rationale: defaults differ (reads `meta.json`), and discoverability under `mosaic gateway --help`. |
|
||||
| Implementation | Share the sign-in logic by extracting a small `runLogin(gatewayUrl, email?, password?)` helper; both commands call it. |
|
||||
|
||||
### 3.3 `mosaic gateway config rotate-token`
|
||||
|
||||
| Aspect | Behavior |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Precondition | Valid session (via `loadSession` + `validateSession`). On failure, print: "Not signed in — run `mosaic gateway login`" and exit non-zero. |
|
||||
| Request | `POST ${gatewayUrl}/api/admin/tokens` with header `Cookie: <session>`, body `{ label: "CLI token (rotated YYYY-MM-DD)" }`. |
|
||||
| On success | Read meta via `readMeta()`, set `meta.adminToken = plaintext`, `writeMeta(meta)`. Print the token banner (reuse `printAdminTokenBanner` shape). |
|
||||
| Old token | **Optional `--revoke-old`** flag. When set and a previous `meta.adminToken` existed, call `DELETE /api/admin/tokens/:id` after rotation. Requires listing first to find the id; punt to CU-03-02 decision. Document as nice-to-have. |
|
||||
| Exit codes | `0` success; `1` network error; `2` auth error; `3` server rejection. |
|
||||
|
||||
### 3.4 `mosaic gateway config recover-token`
|
||||
|
||||
Superset of `rotate-token` with an inline login nudge — the "stranded operator"
|
||||
entry point.
|
||||
|
||||
| Step | Action |
|
||||
| ---- | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | `readMeta()` — derive gateway URL. If meta is missing entirely, fall back to `--gateway` flag or default. |
|
||||
| 2 | `loadSession(gatewayUrl)` then `validateSession`. If either fails, prompt inline: email + password -> `signIn` -> `saveSession`. |
|
||||
| 3 | `POST /api/admin/tokens` with cookie, label `"Recovered via CLI YYYY-MM-DDTHH:mm"`. |
|
||||
| 4 | Persist plaintext to `meta.json` via `writeMeta`. |
|
||||
| 5 | Print the token banner and next-steps hints (e.g. `mosaic gateway status`). |
|
||||
| 6 | Exit `0`. |
|
||||
|
||||
Key property: this command is **runnable with nothing but email+password in hand**.
|
||||
It assumes the gateway is up but assumes no prior CLI session state.
|
||||
|
||||
### 3.5 File touch list (for CU-03-02..05 execution)
|
||||
|
||||
| File | Change |
|
||||
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| `packages/mosaic/src/commands/gateway.ts` | Register `login`, `config recover-token`, `config rotate-token` subcommands under `gw`. |
|
||||
| `packages/mosaic/src/commands/gateway/config.ts` | Add `runRecoverToken`, `runRotateToken` handlers; export from module. |
|
||||
| `packages/mosaic/src/commands/gateway/login.ts` (new) | Thin wrapper calling shared `runLogin` helper with meta-derived default URL. |
|
||||
| `packages/mosaic/src/auth.ts` | No change expected. Possibly export a `requireSession(gatewayUrl)` helper (reuse pattern). |
|
||||
| `packages/mosaic/src/commands/gateway/install.ts` | `bootstrapFirstUser` branch: "user exists, no token" -> offer recovery (see Section 4). |
|
||||
|
||||
## 4. Installer Fix (CU-03-06 preview)
|
||||
|
||||
Current stranding point is `install.ts:388-395`. The fix:
|
||||
|
||||
```
|
||||
if (!status.needsSetup) {
|
||||
if (meta.adminToken) {
|
||||
// unchanged — happy path
|
||||
} else {
|
||||
// NEW: prompt "Admin exists but no token on file. Recover now? [Y/n]"
|
||||
// If yes -> call runRecoverToken(gatewayUrl) inline (interactive):
|
||||
// - prompt email + password
|
||||
// - signIn -> saveSession
|
||||
// - POST /api/admin/tokens
|
||||
// - writeMeta(meta) with returned plaintext
|
||||
// - print banner
|
||||
// If no -> print the current stranded message but include:
|
||||
// "Run `mosaic gateway config recover-token` when ready."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Shape notes (actual code lands in CU-03-06):
|
||||
|
||||
- Extract the recovery body so it can be called **both** from the standalone
|
||||
command and from `bootstrapFirstUser` without duplicating prompts.
|
||||
- Reuse the same `rl` readline interface already open in `bootstrapFirstUser`
|
||||
for the inline prompts.
|
||||
- Preserve non-interactive behavior: if `process.stdin.isTTY` is false, skip the
|
||||
prompt and emit the "run recover-token" hint only.
|
||||
|
||||
## 5. Test Strategy (CU-03-07 scope)
|
||||
|
||||
### 5.1 Happy paths
|
||||
|
||||
| Command | Scenario | Expected |
|
||||
| ------------------------------------- | ------------------------------------------------ | -------------------------------------------------------- |
|
||||
| `mosaic gateway login` | Valid creds | `session.json` written, 7-day expiry, exit 0 |
|
||||
| `mosaic gateway config rotate-token` | Valid session, server reachable | `meta.json` updated, banner printed, new token usable |
|
||||
| `mosaic gateway config recover-token` | No session, valid creds, server reachable | Prompts for creds, writes session + meta, exit 0 |
|
||||
| Installer inline recovery | Re-run after `meta.json` wipe, operator says yes | Meta restored, banner printed, no manual CLI step needed |
|
||||
|
||||
### 5.2 Error paths (must all produce actionable messages and non-zero exit)
|
||||
|
||||
| Failure | Expected handling |
|
||||
| --------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| Invalid email/password | BetterAuth 401 surfaced as "Sign-in failed: <server message>", exit 2 |
|
||||
| Expired stored session | Recover command silently re-prompts; rotate command exits 2 with "run login" hint |
|
||||
| Gateway down / connection refused | "Could not reach gateway at <url>" exit 1 |
|
||||
| Server rejects token creation | Print status + body excerpt, exit 3 |
|
||||
| Meta file missing (recover) | Fall back to `--gateway` flag or default; warn that meta will be created |
|
||||
| Non-admin user | `AdminGuard` 403 surfaced as "User is not an admin", exit 2 |
|
||||
|
||||
### 5.3 Integration test (recommended)
|
||||
|
||||
Spin up gateway in test harness, create admin user via `/api/bootstrap/setup`,
|
||||
wipe `meta.json`, invoke `mosaic gateway config recover-token` programmatically,
|
||||
assert new `meta.adminToken` works against `GET /api/admin/tokens`.
|
||||
|
||||
## 6. Risks & Open Questions
|
||||
|
||||
| # | Item | Severity | Mitigation |
|
||||
| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | `AdminGuard.validateSession` calls `getSession` with `fromNodeHeaders(request.raw.headers)`. CLI sends `Cookie:` header only. Confirm BetterAuth reads from `Cookie`, not `Set-Cookie`. | Low | Confirmed — `mosaic login` + `mosaic tui` already use this flow successfully (`cli.ts:137-181`). |
|
||||
| 2 | Session cookie local expiry (7d) vs BetterAuth server-side expiry may drift. | Low | `validateSession` hits `get-session`; handle 401 by re-prompting. |
|
||||
| 3 | Label collision / unbounded token growth if operators run `recover-token` repeatedly. | Low | Include ISO timestamp in label. Optional `--revoke-old` in CU-03-02. Add `tokens list/prune` later. |
|
||||
| 4 | `mosaic login` exists at top level and `mosaic gateway login` is a wrapper — risk of confusion. | Low | Document that `gateway login` is the preferred entry for gateway operators; top-level stays for compatibility. |
|
||||
| 5 | `meta.json` write is not atomic. Crash between token creation and `writeMeta` leaves an orphan token server-side with no plaintext on disk. | Medium | Accept for now — re-running `recover-token` mints a fresh token. Document as known limitation. |
|
||||
| 6 | Non-TTY installer runs (CI, headless provisioners) cannot prompt for creds interactively. | Medium | Installer inline recovery must skip prompt when `!process.stdin.isTTY`; emit the recover-token hint. |
|
||||
| 7 | If `BETTER_AUTH_SECRET` rotates between login and recover, the session cookie is invalid — user must re-login. Acceptable but surface a clear error. | Low | Error handler maps 401 on recover -> "Session invalid; re-run `mosaic gateway login`". |
|
||||
| 8 | No MFA today. When MFA lands, BetterAuth sign-in will return a challenge, not a cookie — recovery UX will need a second prompt step. | Future | Out of scope for this mission. Flag for future CLI work. |
|
||||
|
||||
## 7. Downstream Task Hooks
|
||||
|
||||
| Task | Scope |
|
||||
| -------- | -------------------------------------------------------------------------- |
|
||||
| CU-03-02 | Implement `mosaic gateway login` wrapper + shared `runLogin` extraction. |
|
||||
| CU-03-03 | Implement `mosaic gateway config rotate-token`. |
|
||||
| CU-03-04 | Implement `mosaic gateway config recover-token`. |
|
||||
| CU-03-05 | Wire commands into `gateway.ts` registration, update `--help` copy. |
|
||||
| CU-03-06 | Installer inline recovery hook in `bootstrapFirstUser`. |
|
||||
| CU-03-07 | Tests per Section 5. |
|
||||
| CU-03-08 | Docs: update gateway install README + operator runbook with recovery flow. |
|
||||
@@ -32,6 +32,7 @@
|
||||
"@mosaicstack/macp": "workspace:*",
|
||||
"@mosaicstack/prdy": "workspace:*",
|
||||
"@mosaicstack/quality-rails": "workspace:*",
|
||||
"@mosaicstack/queue": "workspace:*",
|
||||
"@mosaicstack/types": "workspace:*",
|
||||
"@clack/prompts": "^0.9.1",
|
||||
"commander": "^13.0.0",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { createRequire } from 'module';
|
||||
import { Command } from 'commander';
|
||||
import { registerQualityRails } from '@mosaicstack/quality-rails';
|
||||
import { registerQueueCommand } from '@mosaicstack/queue';
|
||||
import { registerAgentCommand } from './commands/agent.js';
|
||||
import { registerMissionCommand } from './commands/mission.js';
|
||||
// prdy is registered via launch.ts
|
||||
@@ -318,6 +319,10 @@ registerMissionCommand(program);
|
||||
|
||||
registerQualityRails(program);
|
||||
|
||||
// ─── queue ───────────────────────────────────────────────────────────────
|
||||
|
||||
registerQueueCommand(program);
|
||||
|
||||
// ─── update ─────────────────────────────────────────────────────────────
|
||||
|
||||
program
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaicstack/types": "workspace:*",
|
||||
"commander": "^13.0.0",
|
||||
"ioredis": "^5.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
62
packages/queue/src/cli.spec.ts
Normal file
62
packages/queue/src/cli.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Command } from 'commander';
|
||||
import { registerQueueCommand } from './cli.js';
|
||||
|
||||
describe('registerQueueCommand', () => {
|
||||
function buildProgram(): Command {
|
||||
const program = new Command('mosaic');
|
||||
registerQueueCommand(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
it('registers a "queue" subcommand', () => {
|
||||
const program = buildProgram();
|
||||
const queueCmd = program.commands.find((c) => c.name() === 'queue');
|
||||
expect(queueCmd).toBeDefined();
|
||||
});
|
||||
|
||||
it('queue has list, stats, pause, resume, jobs, drain subcommands', () => {
|
||||
const program = buildProgram();
|
||||
const queueCmd = program.commands.find((c) => c.name() === 'queue');
|
||||
expect(queueCmd).toBeDefined();
|
||||
|
||||
const names = queueCmd!.commands.map((c) => c.name());
|
||||
expect(names).toContain('list');
|
||||
expect(names).toContain('stats');
|
||||
expect(names).toContain('pause');
|
||||
expect(names).toContain('resume');
|
||||
expect(names).toContain('jobs');
|
||||
expect(names).toContain('drain');
|
||||
});
|
||||
|
||||
it('jobs subcommand has a "tail" subcommand', () => {
|
||||
const program = buildProgram();
|
||||
const queueCmd = program.commands.find((c) => c.name() === 'queue');
|
||||
const jobsCmd = queueCmd!.commands.find((c) => c.name() === 'jobs');
|
||||
expect(jobsCmd).toBeDefined();
|
||||
|
||||
const tailCmd = jobsCmd!.commands.find((c) => c.name() === 'tail');
|
||||
expect(tailCmd).toBeDefined();
|
||||
});
|
||||
|
||||
it('drain has a --yes option', () => {
|
||||
const program = buildProgram();
|
||||
const queueCmd = program.commands.find((c) => c.name() === 'queue');
|
||||
const drainCmd = queueCmd!.commands.find((c) => c.name() === 'drain');
|
||||
expect(drainCmd).toBeDefined();
|
||||
|
||||
const optionNames = drainCmd!.options.map((o) => o.long);
|
||||
expect(optionNames).toContain('--yes');
|
||||
});
|
||||
|
||||
it('stats accepts an optional [name] argument', () => {
|
||||
const program = buildProgram();
|
||||
const queueCmd = program.commands.find((c) => c.name() === 'queue');
|
||||
const statsCmd = queueCmd!.commands.find((c) => c.name() === 'stats');
|
||||
expect(statsCmd).toBeDefined();
|
||||
// Should not throw when called without argument
|
||||
const args = statsCmd!.registeredArguments;
|
||||
expect(args.length).toBe(1);
|
||||
expect(args[0]!.required).toBe(false);
|
||||
});
|
||||
});
|
||||
248
packages/queue/src/cli.ts
Normal file
248
packages/queue/src/cli.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { createLocalAdapter } from './adapters/local.js';
|
||||
import type { QueueConfig } from './types.js';
|
||||
|
||||
/** Resolve adapter type from env; defaults to 'local'. */
|
||||
function resolveAdapterType(): 'bullmq' | 'local' {
|
||||
const t = process.env['QUEUE_ADAPTER'] ?? 'local';
|
||||
return t === 'bullmq' ? 'bullmq' : 'local';
|
||||
}
|
||||
|
||||
function resolveConfig(): QueueConfig {
|
||||
const type = resolveAdapterType();
|
||||
if (type === 'bullmq') {
|
||||
return { type: 'bullmq', url: process.env['VALKEY_URL'] };
|
||||
}
|
||||
return { type: 'local', dataDir: process.env['QUEUE_DATA_DIR'] };
|
||||
}
|
||||
|
||||
const BULLMQ_ONLY_MSG =
|
||||
'not supported by local adapter — use the bullmq tier for this (set QUEUE_ADAPTER=bullmq)';
|
||||
|
||||
/**
|
||||
* Register queue subcommands on an existing Commander program.
|
||||
* Follows the same pattern as registerQualityRails in @mosaicstack/quality-rails.
|
||||
*/
|
||||
export function registerQueueCommand(parent: Command): void {
|
||||
buildQueueCommand(parent.command('queue').description('Manage Mosaic job queues'));
|
||||
}
|
||||
|
||||
function buildQueueCommand(queue: Command): void {
|
||||
// ─── list ──────────────────────────────────────────────────────────────
|
||||
queue
|
||||
.command('list')
|
||||
.description('List all queues known to the configured adapter')
|
||||
.action(async () => {
|
||||
const config = resolveConfig();
|
||||
|
||||
if (config.type === 'local') {
|
||||
const adapter = createLocalAdapter(config);
|
||||
// Local adapter tracks queues in its internal Map; we expose them by
|
||||
// listing JSON files in the data dir.
|
||||
const { readdirSync } = await import('node:fs');
|
||||
const { existsSync } = await import('node:fs');
|
||||
const dataDir = config.dataDir ?? '.mosaic/queue';
|
||||
if (!existsSync(dataDir)) {
|
||||
console.log('No queues found (data dir does not exist yet).');
|
||||
await adapter.close();
|
||||
return;
|
||||
}
|
||||
const files = readdirSync(dataDir).filter((f: string) => f.endsWith('.json'));
|
||||
if (files.length === 0) {
|
||||
console.log('No queues found.');
|
||||
} else {
|
||||
console.log('Queues (local adapter):');
|
||||
for (const f of files) {
|
||||
console.log(` - ${f.slice(0, -5)}`);
|
||||
}
|
||||
}
|
||||
await adapter.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// bullmq — not enough info to enumerate queues without a BullMQ Board
|
||||
console.log(BULLMQ_ONLY_MSG);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// ─── stats ─────────────────────────────────────────────────────────────
|
||||
queue
|
||||
.command('stats [name]')
|
||||
.description('Show stats for a queue (or all queues)')
|
||||
.action(async (name?: string) => {
|
||||
const config = resolveConfig();
|
||||
|
||||
if (config.type === 'local') {
|
||||
const adapter = createLocalAdapter(config);
|
||||
const { readdirSync } = await import('node:fs');
|
||||
const { existsSync } = await import('node:fs');
|
||||
const dataDir = config.dataDir ?? '.mosaic/queue';
|
||||
|
||||
let names: string[] = [];
|
||||
if (name) {
|
||||
names = [name];
|
||||
} else {
|
||||
if (existsSync(dataDir)) {
|
||||
names = readdirSync(dataDir)
|
||||
.filter((f: string) => f.endsWith('.json'))
|
||||
.map((f: string) => f.slice(0, -5));
|
||||
}
|
||||
}
|
||||
|
||||
if (names.length === 0) {
|
||||
console.log('No queues found.');
|
||||
await adapter.close();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const queueName of names) {
|
||||
const len = await adapter.length(queueName);
|
||||
console.log(`Queue: ${queueName}`);
|
||||
console.log(` waiting: ${len}`);
|
||||
console.log(` active: 0 (local adapter — no active tracking)`);
|
||||
console.log(` completed: 0 (local adapter — no completed tracking)`);
|
||||
console.log(` failed: 0 (local adapter — no failed tracking)`);
|
||||
console.log(` delayed: 0 (local adapter — no delayed tracking)`);
|
||||
}
|
||||
await adapter.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// bullmq
|
||||
console.log(BULLMQ_ONLY_MSG);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// ─── pause ─────────────────────────────────────────────────────────────
|
||||
queue
|
||||
.command('pause <name>')
|
||||
.description('Pause job processing for a queue')
|
||||
.action(async (_name: string) => {
|
||||
const config = resolveConfig();
|
||||
|
||||
if (config.type === 'local') {
|
||||
console.log(BULLMQ_ONLY_MSG);
|
||||
process.exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(BULLMQ_ONLY_MSG);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// ─── resume ────────────────────────────────────────────────────────────
|
||||
queue
|
||||
.command('resume <name>')
|
||||
.description('Resume job processing for a queue')
|
||||
.action(async (_name: string) => {
|
||||
const config = resolveConfig();
|
||||
|
||||
if (config.type === 'local') {
|
||||
console.log(BULLMQ_ONLY_MSG);
|
||||
process.exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(BULLMQ_ONLY_MSG);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// ─── jobs tail ─────────────────────────────────────────────────────────
|
||||
const jobs = queue.command('jobs').description('Job-level operations');
|
||||
|
||||
jobs
|
||||
.command('tail [name]')
|
||||
.description('Stream new jobs as they arrive (poll-based)')
|
||||
.option('--interval <ms>', 'Poll interval in ms', '2000')
|
||||
.action(async (name: string | undefined, opts: { interval: string }) => {
|
||||
const config = resolveConfig();
|
||||
const pollMs = parseInt(opts.interval, 10);
|
||||
|
||||
if (config.type === 'local') {
|
||||
const adapter = createLocalAdapter(config);
|
||||
const { existsSync, readdirSync } = await import('node:fs');
|
||||
const dataDir = config.dataDir ?? '.mosaic/queue';
|
||||
|
||||
let names: string[] = [];
|
||||
if (name) {
|
||||
names = [name];
|
||||
} else {
|
||||
if (existsSync(dataDir)) {
|
||||
names = readdirSync(dataDir)
|
||||
.filter((f: string) => f.endsWith('.json'))
|
||||
.map((f: string) => f.slice(0, -5));
|
||||
}
|
||||
}
|
||||
|
||||
if (names.length === 0) {
|
||||
console.log('No queues to tail.');
|
||||
await adapter.close();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Tailing queues: ${names.join(', ')} (Ctrl-C to stop)`);
|
||||
const lastLen = new Map<string, number>();
|
||||
for (const qn of names) {
|
||||
lastLen.set(qn, await adapter.length(qn));
|
||||
}
|
||||
|
||||
const timer = setInterval(async () => {
|
||||
for (const qn of names) {
|
||||
const len = await adapter.length(qn);
|
||||
const prev = lastLen.get(qn) ?? 0;
|
||||
if (len > prev) {
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] ${qn}: ${len - prev} new job(s) (total: ${len})`,
|
||||
);
|
||||
}
|
||||
lastLen.set(qn, len);
|
||||
}
|
||||
}, pollMs);
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
clearInterval(timer);
|
||||
await adapter.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// bullmq — use subscribe on the channel
|
||||
console.log(BULLMQ_ONLY_MSG);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// ─── drain ─────────────────────────────────────────────────────────────
|
||||
queue
|
||||
.command('drain <name>')
|
||||
.description('Drain all pending jobs from a queue')
|
||||
.option('--yes', 'Skip confirmation prompt')
|
||||
.action(async (name: string, opts: { yes?: boolean }) => {
|
||||
if (!opts.yes) {
|
||||
console.error(
|
||||
`WARNING: This will remove all pending jobs from queue "${name}". Re-run with --yes to confirm.`,
|
||||
);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const config = resolveConfig();
|
||||
|
||||
if (config.type === 'local') {
|
||||
const adapter = createLocalAdapter(config);
|
||||
let removed = 0;
|
||||
while ((await adapter.length(name)) > 0) {
|
||||
await adapter.dequeue(name);
|
||||
removed++;
|
||||
}
|
||||
console.log(`Drained ${removed} job(s) from queue "${name}".`);
|
||||
await adapter.close();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(BULLMQ_ONLY_MSG);
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export { type QueueAdapter, type QueueConfig as QueueAdapterConfig } from './typ
|
||||
export { createQueueAdapter, registerQueueAdapter } from './factory.js';
|
||||
export { createBullMQAdapter } from './adapters/bullmq.js';
|
||||
export { createLocalAdapter } from './adapters/local.js';
|
||||
export { registerQueueCommand } from './cli.js';
|
||||
|
||||
import { registerQueueAdapter } from './factory.js';
|
||||
import { createBullMQAdapter } from './adapters/bullmq.js';
|
||||
|
||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -469,6 +469,9 @@ importers:
|
||||
'@mosaicstack/quality-rails':
|
||||
specifier: workspace:*
|
||||
version: link:../quality-rails
|
||||
'@mosaicstack/queue':
|
||||
specifier: workspace:*
|
||||
version: link:../queue
|
||||
'@mosaicstack/types':
|
||||
specifier: workspace:*
|
||||
version: link:../types
|
||||
@@ -565,6 +568,9 @@ importers:
|
||||
'@mosaicstack/types':
|
||||
specifier: workspace:*
|
||||
version: link:../types
|
||||
commander:
|
||||
specifier: ^13.0.0
|
||||
version: 13.1.0
|
||||
ioredis:
|
||||
specifier: ^5.10.0
|
||||
version: 5.10.0
|
||||
|
||||
Reference in New Issue
Block a user