@exaudeus/workrail 3.32.0 → 3.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands/index.d.ts +1 -0
- package/dist/cli/commands/index.js +3 -1
- package/dist/cli/commands/worktrain-await.js +11 -9
- package/dist/cli/commands/worktrain-daemon-install.d.ts +35 -0
- package/dist/cli/commands/worktrain-daemon-install.js +291 -0
- package/dist/cli/commands/worktrain-daemon.d.ts +31 -0
- package/dist/cli/commands/worktrain-daemon.js +272 -0
- package/dist/cli/commands/worktrain-spawn.js +11 -9
- package/dist/cli-worktrain.js +488 -0
- package/dist/cli.js +1 -22
- package/dist/console/standalone-console.d.ts +28 -0
- package/dist/console/standalone-console.js +142 -0
- package/dist/{console/assets/index-Cb_LO718.js → console-ui/assets/index-C1JXnwZS.js} +1 -1
- package/dist/{console → console-ui}/index.html +1 -1
- package/dist/daemon/agent-loop.d.ts +27 -0
- package/dist/daemon/agent-loop.js +39 -1
- package/dist/daemon/daemon-events.d.ts +63 -1
- package/dist/daemon/workflow-runner.d.ts +3 -2
- package/dist/daemon/workflow-runner.js +285 -46
- package/dist/infrastructure/session/HttpServer.js +133 -34
- package/dist/manifest.json +136 -104
- package/dist/mcp/handlers/v2-error-mapping.d.ts +3 -0
- package/dist/mcp/handlers/v2-error-mapping.js +2 -0
- package/dist/mcp/handlers/v2-execution/advance.js +25 -0
- package/dist/mcp/handlers/v2-execution/continue-advance.js +7 -0
- package/dist/mcp/output-schemas.d.ts +30 -30
- package/dist/mcp/transports/fatal-exit.js +4 -0
- package/dist/mcp/transports/http-entry.js +0 -5
- package/dist/mcp/transports/stdio-entry.js +24 -12
- package/dist/mcp/v2/tools.d.ts +4 -4
- package/dist/mcp-server.d.ts +0 -2
- package/dist/mcp-server.js +1 -42
- package/dist/trigger/adapters/github-poller.d.ts +44 -0
- package/dist/trigger/adapters/github-poller.js +190 -0
- package/dist/trigger/adapters/gitlab-poller.d.ts +27 -0
- package/dist/trigger/adapters/gitlab-poller.js +81 -0
- package/dist/trigger/index.d.ts +4 -1
- package/dist/trigger/index.js +5 -1
- package/dist/trigger/polled-event-store.d.ts +22 -0
- package/dist/trigger/polled-event-store.js +173 -0
- package/dist/trigger/polling-scheduler.d.ts +20 -0
- package/dist/trigger/polling-scheduler.js +249 -0
- package/dist/trigger/trigger-listener.d.ts +3 -0
- package/dist/trigger/trigger-listener.js +47 -3
- package/dist/trigger/trigger-store.js +114 -33
- package/dist/trigger/types.d.ts +17 -1
- package/dist/v2/durable-core/domain/observation-builder.d.ts +3 -0
- package/dist/v2/durable-core/domain/observation-builder.js +2 -2
- package/dist/v2/durable-core/domain/prompt-renderer.d.ts +2 -1
- package/dist/v2/durable-core/domain/prompt-renderer.js +10 -0
- package/dist/v2/durable-core/schemas/export-bundle/index.d.ts +224 -224
- package/dist/v2/durable-core/schemas/session/events.d.ts +42 -42
- package/dist/v2/durable-core/schemas/session/manifest.d.ts +6 -6
- package/dist/v2/durable-core/schemas/session/validation-event.d.ts +2 -2
- package/dist/v2/durable-core/tokens/payloads.d.ts +52 -52
- package/dist/v2/usecases/console-routes.js +3 -3
- package/dist/v2/usecases/console-service.js +185 -10
- package/dist/v2/usecases/console-types.d.ts +8 -0
- package/docs/design/bridge-removal-pr-a-candidates.md +115 -0
- package/docs/design/bridge-removal-pr-a-design-review.md +79 -0
- package/docs/design/bridge-removal-pr-a-implementation-plan.md +203 -0
- package/docs/design/daemon-conversation-logging-plan.md +98 -0
- package/docs/design/daemon-conversation-logging-review.md +55 -0
- package/docs/design/daemon-conversation-logging.md +129 -0
- package/docs/design/github-polling-adapter-design-candidates.md +226 -0
- package/docs/design/github-polling-adapter-design-review-findings.md +131 -0
- package/docs/design/github-polling-adapter-implementation-plan.md +284 -0
- package/docs/design/implementation_plan.md +192 -0
- package/docs/design/workflow-id-validation-at-startup.md +146 -0
- package/docs/design/workflow-id-validation-design-review.md +87 -0
- package/docs/design/workflow-id-validation-implementation-plan.md +185 -0
- package/docs/design/worktrain-system-prompt-report-issue-candidates.md +135 -0
- package/docs/design/worktrain-system-prompt-report-issue-design-review.md +73 -0
- package/docs/discovery/design-candidates.md +180 -0
- package/docs/discovery/design-review-findings.md +110 -0
- package/docs/discovery/wr-discovery-goal-reframing.md +303 -0
- package/docs/ideas/backlog.md +627 -0
- package/package.json +1 -1
- package/workflows/architecture-scalability-audit.json +1 -1
- package/workflows/bug-investigation.agentic.v2.json +3 -3
- package/workflows/coding-task-workflow-agentic.json +32 -32
- package/workflows/coding-task-workflow-agentic.lean.v2.json +1 -1
- package/workflows/coding-task-workflow-agentic.v2.json +7 -7
- package/workflows/mr-review-workflow.agentic.v2.json +21 -12
- package/workflows/personal-learning-materials-creation-branched.json +2 -2
- package/workflows/production-readiness-audit.json +1 -1
- package/workflows/relocation-workflow-us.json +2 -2
- package/workflows/ui-ux-design-workflow.json +14 -14
- package/workflows/workflow-for-workflows.json +3 -3
- package/workflows/workflow-for-workflows.v2.json +2 -2
- package/workflows/wr.discovery.json +59 -8
- package/dist/mcp/transports/bridge-entry.d.ts +0 -102
- package/dist/mcp/transports/bridge-entry.js +0 -454
- package/dist/mcp/transports/bridge-events.d.ts +0 -51
- package/dist/mcp/transports/bridge-events.js +0 -24
- package/dist/mcp/transports/primary-tombstone.d.ts +0 -21
- package/dist/mcp/transports/primary-tombstone.js +0 -51
- /package/dist/{console → console-ui}/assets/index-8dh0Psu-.css +0 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Implementation Plan: Daemon Conversation Logging
|
|
2
|
+
|
|
3
|
+
## Problem Statement
|
|
4
|
+
|
|
5
|
+
The WorkRail daemon runs workflows autonomously but provides minimal visibility into what the agent is actually doing. Today you can see `session_started`, `tool_called`, and `session_completed` in the JSONL event log - but you cannot see what the LLM decided, which tools it requested, how long each tool took, or whether a tool succeeded. Adding `llm_turn_started`, `llm_turn_completed`, `tool_call_started`, `tool_call_completed`, and `tool_call_failed` events - plus a `worktrain logs` CLI command - turns the event file into a real-time audit trail of agent behavior.
|
|
6
|
+
|
|
7
|
+
## Acceptance Criteria
|
|
8
|
+
|
|
9
|
+
1. After an LLM API call in `_runLoop()`, `llm_turn_started` is written before the call and `llm_turn_completed` after the response.
|
|
10
|
+
2. For every tool execution via `_executeTools()`, `tool_call_started` is written before `tool.execute()`, and either `tool_call_completed` or `tool_call_failed` is written after.
|
|
11
|
+
3. `tool_call_started` args are truncated to max 200 chars. `tool_call_completed` result summary truncated to max 200 chars.
|
|
12
|
+
4. All new events appear in the same daily JSONL file as existing events.
|
|
13
|
+
5. `worktrain logs` reads today's log file and prints each event formatted for humans.
|
|
14
|
+
6. `worktrain logs --follow` polls the file every 500ms and prints new events as they arrive.
|
|
15
|
+
7. `worktrain logs --session <id>` filters events to those with matching `sessionId`.
|
|
16
|
+
8. `worktrain logs --follow` handles midnight file rotation (switches to new date file).
|
|
17
|
+
9. If the log file doesn't exist, `worktrain logs` prints a helpful message; `--follow` waits for the file.
|
|
18
|
+
10. TypeScript compiles without errors. Existing tests pass.
|
|
19
|
+
|
|
20
|
+
## Non-Goals
|
|
21
|
+
|
|
22
|
+
- NOT putting events in the v2 session event store
|
|
23
|
+
- NOT adding a Console Timeline tab
|
|
24
|
+
- NOT deprecating `tool_called` events (backward compat)
|
|
25
|
+
- NOT implementing accurate pre-call token counting (message count proxy is sufficient)
|
|
26
|
+
- NOT searching across multiple day files for `--session` filter
|
|
27
|
+
|
|
28
|
+
## Philosophy-Driven Constraints
|
|
29
|
+
|
|
30
|
+
- **Fire-and-forget invariant**: All callbacks in AgentLoop are wrapped in try/catch that swallow errors.
|
|
31
|
+
- **DI for boundaries**: AgentLoop receives callbacks, not DaemonEventEmitter itself.
|
|
32
|
+
- **Make illegal states unrepresentable**: New event kinds added to `DaemonEvent` discriminated union.
|
|
33
|
+
- **YAGNI**: Only the specified event kinds and fields.
|
|
34
|
+
|
|
35
|
+
## Invariants
|
|
36
|
+
|
|
37
|
+
1. `tool_call_started` is always followed by either `tool_call_completed` or `tool_call_failed`.
|
|
38
|
+
2. `llm_turn_started` may have no matching `llm_turn_completed` on API error - this is intentional signal.
|
|
39
|
+
3. Callbacks in AgentLoop never propagate exceptions to the caller.
|
|
40
|
+
4. `DaemonEvent` union remains exhaustive.
|
|
41
|
+
|
|
42
|
+
## Selected Approach
|
|
43
|
+
|
|
44
|
+
AgentLoopOptions callbacks: 5 optional callback properties on `AgentLoopOptions` called in `_runLoop()` and `_executeTools()`. workflow-runner.ts wires them to `emitter?.emit()`.
|
|
45
|
+
|
|
46
|
+
## Vertical Slices
|
|
47
|
+
|
|
48
|
+
### Slice 1: New event types in daemon-events.ts
|
|
49
|
+
- Add interfaces: `LlmTurnStartedEvent`, `LlmTurnCompletedEvent`, `ToolCallStartedEvent`, `ToolCallCompletedEvent`, `ToolCallFailedEvent`
|
|
50
|
+
- Extend `DaemonEvent` union with all 5
|
|
51
|
+
|
|
52
|
+
### Slice 2: AgentLoopOptions callbacks + emission in agent-loop.ts
|
|
53
|
+
- Add 5 optional callbacks to `AgentLoopOptions`
|
|
54
|
+
- Call with try/catch in `_runLoop()` before/after `client.messages.create()`
|
|
55
|
+
- Call with try/catch in `_executeTools()` before/after `tool.execute()`
|
|
56
|
+
- Add `Date.now()` timing for tool calls
|
|
57
|
+
|
|
58
|
+
### Slice 3: Wire callbacks in workflow-runner.ts
|
|
59
|
+
- In `runWorkflow()`, pass `AgentLoop` constructor the 5 callbacks
|
|
60
|
+
- Each callback calls `emitter?.emit()` with the appropriate new event kind
|
|
61
|
+
|
|
62
|
+
### Slice 4: `worktrain logs` CLI command
|
|
63
|
+
- Add `program.command('logs')` with `--follow` and `--session <id>` options
|
|
64
|
+
- Read daily JSONL, format each line, handle ENOENT
|
|
65
|
+
- Polling loop with midnight rotation
|
|
66
|
+
|
|
67
|
+
### Slice 5: Tests
|
|
68
|
+
- `daemon-events.test.ts`: Add 5 new event kinds to exhaustiveness test
|
|
69
|
+
- `agent-loop.test.ts`: Add tests for callback timing, completion, failure, and try/catch guards
|
|
70
|
+
|
|
71
|
+
## Test Design
|
|
72
|
+
|
|
73
|
+
- onToolCallStarted fires before tool execute (verified via call order recording)
|
|
74
|
+
- onToolCallCompleted fires after successful execute (verified with durationMs > 0)
|
|
75
|
+
- onToolCallFailed fires when tool throws (loop continues normally)
|
|
76
|
+
- onLlmTurnStarted fires with correct messageCount before API call
|
|
77
|
+
- onLlmTurnCompleted fires with actual token counts from API response
|
|
78
|
+
- Callbacks that throw do not crash the loop
|
|
79
|
+
|
|
80
|
+
## Risk Register
|
|
81
|
+
|
|
82
|
+
| Risk | Mitigation |
|
|
83
|
+
|---|---|
|
|
84
|
+
| Callback throws crash the session | try/catch on all 5 callback invocations |
|
|
85
|
+
| --follow misses events at midnight | Date-check on each poll iteration |
|
|
86
|
+
|
|
87
|
+
## PR Strategy
|
|
88
|
+
|
|
89
|
+
Single PR: `feat/daemon-conversation-logging`
|
|
90
|
+
|
|
91
|
+
## Philosophy Alignment
|
|
92
|
+
|
|
93
|
+
- DI for boundaries: Satisfied (callbacks, not DaemonEventEmitter in AgentLoop)
|
|
94
|
+
- Make illegal states unrepresentable: Satisfied (discriminated union)
|
|
95
|
+
- Errors are data: Satisfied (tool throws -> tool_call_failed, not propagated)
|
|
96
|
+
- Fire-and-forget: Satisfied (try/catch guards)
|
|
97
|
+
- YAGNI: Satisfied
|
|
98
|
+
- Exhaustiveness: Satisfied (union extended + test updated)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Daemon Conversation Logging: Design Review Findings
|
|
2
|
+
|
|
3
|
+
## Tradeoff Review
|
|
4
|
+
|
|
5
|
+
| Tradeoff | Assessment | Conditions for failure |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| AgentLoopOptions gains 5 optional callbacks | Acceptable - all optional, zero cost when absent | Would matter if AgentLoop were a versioned public library |
|
|
8
|
+
| Dual `tool_called` + `tool_call_started` events in log | Minor duplication, harmless - different fields, different consumers | If a consumer enforced "one event per tool execution" |
|
|
9
|
+
| `llm_turn_started` uses message count (proxy) | Spec-compliant - user explicitly said "estimate from message count" | If accurate pre-call token counts were needed for routing |
|
|
10
|
+
| `--follow` polls at 500ms interval | Acceptable for human-readable monitoring | If sub-100ms stream was required |
|
|
11
|
+
|
|
12
|
+
## Failure Mode Review
|
|
13
|
+
|
|
14
|
+
| Failure mode | Status | Mitigation |
|
|
15
|
+
|---|---|---|
|
|
16
|
+
| Callback throws, propagates into agent loop | **UNMITIGATED - REQUIRES FIX** | Add try/catch around all 5 callback invocations in agent-loop.ts |
|
|
17
|
+
| `tool_call_started` without matching `tool_call_completed` | Handled - catch block emits `tool_call_failed` | No action needed |
|
|
18
|
+
| `llm_turn_started` without matching `llm_turn_completed` (API error) | Acceptable - unmatched started = API error signal | No action needed |
|
|
19
|
+
| `--follow` misses events at midnight file rotation | **REQUIRES FIX** | Check `new Date()` on each poll; switch to new file when date changes |
|
|
20
|
+
| Log file doesn't exist (daemon not started) | Handled - ENOENT returns graceful message | No action needed |
|
|
21
|
+
|
|
22
|
+
## Runner-Up / Simpler Alternative Review
|
|
23
|
+
|
|
24
|
+
- **Runner-up (Candidate B, per-tool factories)**: No elements worth borrowing. Centralizing in `_executeTools()` is strictly better.
|
|
25
|
+
- **Simpler alternative (no AgentLoop changes + `turn_end` subscriber for LLM events)**: Fails spec - `turn_end` fires after tool results, not after API response. Not a valid simplification.
|
|
26
|
+
- **Hybrid (callbacks for LLM, per-factory for tools)**: Two patterns for the same concern. Worse than either pure approach.
|
|
27
|
+
|
|
28
|
+
## Philosophy Alignment
|
|
29
|
+
|
|
30
|
+
**Satisfied**: DI for boundaries, immutability, make illegal states unrepresentable, errors as data, determinism, YAGNI, validate at boundaries.
|
|
31
|
+
|
|
32
|
+
**Under tension (acceptable)**:
|
|
33
|
+
- Type safety: `argsSummary` is deliberately a truncated string - this is spec-required (max 200 chars) and appropriate for JSONL serialization.
|
|
34
|
+
- Exhaustiveness: DaemonEvent union grows by 5; no switch consumers exist so this is theoretical only.
|
|
35
|
+
|
|
36
|
+
## Findings
|
|
37
|
+
|
|
38
|
+
### Red (blocking)
|
|
39
|
+
None.
|
|
40
|
+
|
|
41
|
+
### Orange (should fix before implementation)
|
|
42
|
+
1. **Missing try/catch around callbacks in agent-loop.ts**: A buggy callback passed to `AgentLoop` would propagate a throw into the agent loop and crash the session. This violates the fire-and-forget invariant that all observability in the daemon upholds. Fix: wrap each of the 5 callback invocations with `try { callback(info); } catch { /* swallow */ }`.
|
|
43
|
+
|
|
44
|
+
### Yellow (fix during implementation)
|
|
45
|
+
2. **Midnight file rotation in `--follow`**: The polling loop should check `new Date().toISOString().slice(0, 10)` on each iteration and switch to the new file when the date changes. 3-line fix in the polling loop.
|
|
46
|
+
|
|
47
|
+
## Recommended Revisions
|
|
48
|
+
|
|
49
|
+
1. Add try/catch guards around all callback invocations in `_runLoop()` and `_executeTools()` in `agent-loop.ts`.
|
|
50
|
+
2. Add date-aware file switching in the `--follow` polling loop in `cli-worktrain.ts`.
|
|
51
|
+
|
|
52
|
+
## Residual Concerns
|
|
53
|
+
|
|
54
|
+
- The `tool_called` + `tool_call_started` dual events: a future cleanup task could deprecate `tool_called` once all consumers migrate to `tool_call_started`. Not in scope for this PR.
|
|
55
|
+
- The `worktrain logs` command reads from the daily JSONL file directly. If sessions span multiple days, `--session <id>` would only find events in the current day's file. A future improvement could search across all files. Not in scope.
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Daemon Conversation Logging: Design Candidates
|
|
2
|
+
|
|
3
|
+
## Problem Understanding
|
|
4
|
+
|
|
5
|
+
### Core tensions
|
|
6
|
+
|
|
7
|
+
1. **AgentLoop decoupling vs. LLM turn visibility**: AgentLoop is intentionally decoupled from all observability infrastructure (no DaemonEventEmitter import). To emit LLM turn events FROM inside `_runLoop()`, we need to bridge this gap without coupling AgentLoop to daemon-specific types. Options: inject callbacks, use the existing AgentEvent subscriber system, or violate the boundary. The subscriber system fires at `turn_end` (after tool results), which is not the right boundary for `llm_turn_started` / `llm_turn_completed`. Callbacks are the right choice.
|
|
8
|
+
|
|
9
|
+
2. **Single-source vs. dual-source tool events**: Today each tool factory (`makeBashTool`, `makeReadTool`, etc.) emits `tool_called` directly. Adding `tool_call_started/completed/failed` in `_executeTools()` creates a single centralized emission point. The existing `tool_called` events remain for backward compatibility; new event kinds are additive.
|
|
10
|
+
|
|
11
|
+
3. **Input token estimation**: True token counts require a tokenizer (tiktoken or the API's usage field). The API returns `response.usage.input_tokens` and `response.usage.output_tokens` in the response. For `llm_turn_started`, emit message count as proxy. For `llm_turn_completed`, emit actual token counts from the API response.
|
|
12
|
+
|
|
13
|
+
4. **`worktrain logs --follow` streaming**: Node.js file watching is noisy; polling every 500ms is reliable and simple for MVP.
|
|
14
|
+
|
|
15
|
+
### What makes this hard
|
|
16
|
+
|
|
17
|
+
Nothing is architecturally hard. The tricky parts are:
|
|
18
|
+
- Getting tool event timing exactly right (started before execute, completed/failed after)
|
|
19
|
+
- For `worktrain logs --follow`: handling the case where the log file doesn't exist yet
|
|
20
|
+
- TypeScript type checking: callback signatures must be precise for ts-strict
|
|
21
|
+
|
|
22
|
+
### Likely seam
|
|
23
|
+
|
|
24
|
+
The real seam for tool events is `_executeTools()` in `agent-loop.ts` - it's the single place all tools execute. The real seam for LLM turn events is the `client.messages.create()` call in `_runLoop()`. Both are in `agent-loop.ts`.
|
|
25
|
+
|
|
26
|
+
## Philosophy Constraints
|
|
27
|
+
|
|
28
|
+
From `CLAUDE.md` (system-wide):
|
|
29
|
+
- **DI for boundaries**: inject external effects (observability) to keep core logic testable
|
|
30
|
+
- **YAGNI with discipline**: no speculative fields beyond what's in the spec
|
|
31
|
+
- **Exhaustiveness everywhere**: new event kinds extend the `DaemonEvent` discriminated union
|
|
32
|
+
- **Fire-and-forget invariant**: `emit()` is void, errors swallowed - observability never affects correctness
|
|
33
|
+
- **Prefer fakes over mocks**: FakeAnthropicClient pattern in agent-loop tests
|
|
34
|
+
|
|
35
|
+
No philosophy conflicts found between stated principles and existing repo patterns.
|
|
36
|
+
|
|
37
|
+
## Impact Surface
|
|
38
|
+
|
|
39
|
+
- `runWorkflow()` in `workflow-runner.ts`: constructs AgentLoop, must pass new callbacks
|
|
40
|
+
- `AgentLoopOptions` interface: extended with optional callbacks (non-breaking)
|
|
41
|
+
- `DaemonEvent` union: extended with new members (exhaustiveness tests must update)
|
|
42
|
+
- `tests/unit/daemon-events.test.ts`: the exhaustiveness test at line 169 must list new event kinds
|
|
43
|
+
- `tests/unit/agent-loop.test.ts`: needs tests for callback invocation timing
|
|
44
|
+
- No public API changes - all daemon-internal
|
|
45
|
+
|
|
46
|
+
## Candidates
|
|
47
|
+
|
|
48
|
+
### Candidate A: AgentLoopOptions callbacks (recommended)
|
|
49
|
+
|
|
50
|
+
**Summary**: Add 5 optional callback properties to `AgentLoopOptions` in `agent-loop.ts`. Call them synchronously in `_runLoop()` and `_executeTools()`. Wire in `workflow-runner.ts` to call `emitter?.emit()`.
|
|
51
|
+
|
|
52
|
+
**New properties on AgentLoopOptions**:
|
|
53
|
+
```typescript
|
|
54
|
+
onLlmTurnStarted?: (info: { messageCount: number }) => void
|
|
55
|
+
onLlmTurnCompleted?: (info: {
|
|
56
|
+
stopReason: string;
|
|
57
|
+
outputTokens: number;
|
|
58
|
+
inputTokens: number;
|
|
59
|
+
toolNamesRequested: string[];
|
|
60
|
+
}) => void
|
|
61
|
+
onToolCallStarted?: (info: { toolName: string; argsSummary: string }) => void
|
|
62
|
+
onToolCallCompleted?: (info: { toolName: string; durationMs: number; resultSummary: string }) => void
|
|
63
|
+
onToolCallFailed?: (info: { toolName: string; durationMs: number; errorMessage: string }) => void
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**Tensions resolved**: AgentLoop stays decoupled from DaemonEventEmitter. Single source of truth for tool event timing.
|
|
67
|
+
|
|
68
|
+
**Boundary**: AgentLoop / workflow-runner.ts interface. Correct seam - AgentLoop is a reusable primitive; workflow-runner.ts is the daemon-specific orchestrator.
|
|
69
|
+
|
|
70
|
+
**Failure mode**: If a callback throws, it propagates into the agent loop. Mitigated by: callbacks call `emitter?.emit()` which is fire-and-forget and never throws.
|
|
71
|
+
|
|
72
|
+
**Follows existing pattern**: `DaemonRegistry` uses the same inject-as-optional pattern. `toolExecution: 'sequential'` is already a strategy parameter on `AgentLoopOptions`.
|
|
73
|
+
|
|
74
|
+
**Gains**: Central timing; no changes to individual tool factories; clean separation; new tools get events automatically.
|
|
75
|
+
**Gives up**: `AgentLoopOptions` interface is slightly heavier (5 optional callbacks). Callbacks are less discoverable than per-tool pattern.
|
|
76
|
+
|
|
77
|
+
**Scope**: best-fit.
|
|
78
|
+
|
|
79
|
+
**Philosophy**: honors DI-for-boundaries, YAGNI, exhaustiveness. No conflicts.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
### Candidate B: Extend per-tool factory pattern (adapt existing)
|
|
84
|
+
|
|
85
|
+
**Summary**: Keep the existing per-tool `emitter?.emit({ kind: 'tool_called' })` approach. Add `tool_call_started` emit before `tool.execute()` and `tool_call_completed`/`tool_call_failed` after, inside each of the 5 tool factory closures. Add LLM turn callbacks to `AgentLoopOptions` only for the LLM-specific events.
|
|
86
|
+
|
|
87
|
+
**Tensions resolved**: Minimizes changes to AgentLoop (only 2 callbacks instead of 5). Follows the exact existing pattern.
|
|
88
|
+
|
|
89
|
+
**Boundary**: Each tool factory is the emission point.
|
|
90
|
+
|
|
91
|
+
**Failure mode**: 5 tool factories x 3 events each = 15 new emit calls. Duplication risk. New tools added later won't automatically get events.
|
|
92
|
+
|
|
93
|
+
**Follows existing pattern**: Pure adaptation of the existing `tool_called` pattern.
|
|
94
|
+
|
|
95
|
+
**Gains**: No callbacks for tool events in AgentLoopOptions; no risk of propagated errors.
|
|
96
|
+
**Gives up**: DRY principle - timing logic duplicated 5x. Maintenance trap.
|
|
97
|
+
|
|
98
|
+
**Scope**: best-fit for existing tools, but creates technical debt.
|
|
99
|
+
|
|
100
|
+
**Philosophy**: conflicts with "compose with small, pure functions" (duplication). Honors DI-for-boundaries.
|
|
101
|
+
|
|
102
|
+
## Comparison and Recommendation
|
|
103
|
+
|
|
104
|
+
**Recommendation: Candidate A**
|
|
105
|
+
|
|
106
|
+
Candidate A wins on every meaningful dimension:
|
|
107
|
+
- **Best-fit boundary**: `_executeTools()` is the single canonical execution point for all tools.
|
|
108
|
+
- **Most manageable failure mode**: callbacks call `emitter?.emit()` which can never throw.
|
|
109
|
+
- **Best philosophy fit**: "Compose with small, pure functions" and "DI for boundaries" both point to A.
|
|
110
|
+
- **Easiest to evolve**: Adding a 6th tool gets events automatically.
|
|
111
|
+
- **Consistent with repo patterns**: Same pattern as `DaemonRegistry` injection.
|
|
112
|
+
|
|
113
|
+
## Self-Critique
|
|
114
|
+
|
|
115
|
+
**Strongest argument against**: Candidate A adds 5 callback properties to `AgentLoopOptions`. If `AgentLoop` is used in tests without an emitter, the interface is heavier. Counter: all 5 are optional (`?`), zero cost when absent.
|
|
116
|
+
|
|
117
|
+
**Narrower option that was considered**: Only add LLM turn callbacks (skip `tool_call_started/completed/failed`). Doesn't satisfy the spec.
|
|
118
|
+
|
|
119
|
+
**Broader option**: Put the emitter directly in `AgentLoopOptions`. Would require `AgentLoop` to import `DaemonEventEmitter`, coupling the modules. Unjustified.
|
|
120
|
+
|
|
121
|
+
**Invalidating assumption**: None. `_executeTools()` is the only tool execution path in `AgentLoop`.
|
|
122
|
+
|
|
123
|
+
## Open Questions for Implementation
|
|
124
|
+
|
|
125
|
+
1. The existing `tool_called` events in per-tool factories (`makeBashTool`, `makeReadTool`, `makeWriteTool`, `makeContinueWorkflowTool`) - keep them as-is for backward compat, or remove them now that `tool_call_started` supersedes them? Decision: keep for backward compat since consumers may depend on them.
|
|
126
|
+
|
|
127
|
+
2. For the `worktrain logs --follow` command, should it print historical lines first then follow? Yes - show existing entries then poll for new ones.
|
|
128
|
+
|
|
129
|
+
3. Should `worktrain logs --session <id>` filter by exact sessionId match? Yes.
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# GitHub Polling Adapter: Design Candidates
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-04-15
|
|
4
|
+
**Type:** Design Candidates
|
|
5
|
+
**Status:** Draft -- for review
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Problem Understanding
|
|
10
|
+
|
|
11
|
+
### The Ask
|
|
12
|
+
|
|
13
|
+
Add GitHub Issues and GitHub PRs polling adapters that mirror the GitLab adapter pattern. Poll GitHub APIs on a configurable schedule, deduplicate via `PolledEventStore`, dispatch workflows via `TriggerRouter.dispatch()`. Must include `excludeAuthors` filter to prevent self-review loops.
|
|
14
|
+
|
|
15
|
+
### Infrastructure Context
|
|
16
|
+
|
|
17
|
+
The GitLab adapter infrastructure lives in the `feat-polling-triggers` worktree (PR #404, in-flight). It is NOT merged to `main` yet. The GitHub adapter work must be built on top of that branch. Files in scope:
|
|
18
|
+
|
|
19
|
+
- `src/trigger/adapters/gitlab-poller.ts` -- the direct template
|
|
20
|
+
- `src/trigger/polling-scheduler.ts` -- orchestration; calls adapters and dispatches
|
|
21
|
+
- `src/trigger/polled-event-store.ts` -- per-trigger deduplication state on disk
|
|
22
|
+
- `src/trigger/types.ts` -- `GitLabPollingSource`, `TriggerDefinition.pollingSource`
|
|
23
|
+
- `src/trigger/trigger-store.ts` -- YAML parser, `SUPPORTED_PROVIDERS` set
|
|
24
|
+
|
|
25
|
+
### Core Tensions
|
|
26
|
+
|
|
27
|
+
**1. Shared `source:` YAML block vs. type-safe discriminated union.**
|
|
28
|
+
The YAML parser uses one `source:` block with shared field names. GitLab uses `projectId`; GitHub uses `repo`. If reusing the same block, the raw parsed type is a union of optional fields -- structurally looser than the assembled typed model. The discriminated union only kicks in after assembly; the raw parser type cannot be discriminated cleanly.
|
|
29
|
+
|
|
30
|
+
**2. `pollingSource` field migration vs. backward compatibility.**
|
|
31
|
+
The current `TriggerDefinition.pollingSource?: GitLabPollingSource` field has a code comment requesting migration to a discriminated union at adapter #2 (this change). All existing narrowing in `polling-scheduler.ts` and `trigger-store.ts` must be updated. The impact surface is small but must be handled atomically.
|
|
32
|
+
|
|
33
|
+
**3. GitHub PR `updated_at` vs. GitLab `updated_after` API semantics.**
|
|
34
|
+
GitLab issues endpoint accepts `updated_after=<ISO8601>` as a server-side filter. GitHub PRs endpoint does NOT accept a `since` parameter -- must fetch all open PRs sorted by `updated` desc, then filter client-side where `updated_at > lastPollAt`. This means more items are fetched per cycle and the deduplication load shifts to the client.
|
|
35
|
+
|
|
36
|
+
**4. `notLabels` client-side filter vs. pagination.**
|
|
37
|
+
GitHub API supports `labels=foo,bar` (include filter) but has no native exclude. The `notLabels` filter runs client-side. But with only 100 items per page and no pagination, filtering out many items means some new items may be missed. Accepted limitation -- same as the GitLab adapter's pagination limitation.
|
|
38
|
+
|
|
39
|
+
### What Makes This Hard
|
|
40
|
+
|
|
41
|
+
A junior developer would:
|
|
42
|
+
1. Skip the discriminated union migration and leave `pollingSource` as a bare `GitLabPollingSource | GitHubPollingSource` union -- the compiler cannot enforce which source type accompanies which provider string
|
|
43
|
+
2. Forget to add `github_issues_poll` and `github_prs_poll` to `SUPPORTED_PROVIDERS` in `trigger-store.ts` -- triggers would silently fail with `unknown_provider` at config load
|
|
44
|
+
3. Use `lastPollAt` as a GitHub PRs `since` parameter (the API ignores it -- no such parameter exists for PRs)
|
|
45
|
+
4. Miss the rate limit header check -- at 5000 req/hour the math is fine for normal use, but a burst or misconfiguration could exhaust it without warning
|
|
46
|
+
5. Place the `excludeAuthors` filter AFTER the dispatch call, creating the self-loop it was meant to prevent
|
|
47
|
+
|
|
48
|
+
### Likely Seam
|
|
49
|
+
|
|
50
|
+
`polling-scheduler.ts` is the coordination point. It must route to the correct adapter based on `trigger.provider`. The current code calls `pollGitLabMRs` directly -- this must become provider-aware dispatch.
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Philosophy Constraints
|
|
55
|
+
|
|
56
|
+
From `CLAUDE.md` (hard rules):
|
|
57
|
+
|
|
58
|
+
- **Errors are data** -- all public functions return `Result<T, E>`, never throw
|
|
59
|
+
- **Immutability by default** -- all types `readonly`, all interfaces use `readonly` fields
|
|
60
|
+
- **Make illegal states unrepresentable** -- the TODO in `types.ts` for discriminated union is exactly this principle; the union migration fulfills it
|
|
61
|
+
- **Validate at boundaries, trust inside** -- `trigger-store.ts` validates all fields at parse time; adapters receive already-validated config
|
|
62
|
+
- **Type safety as first line of defense** -- branded `TriggerId`, explicit `Result` types, type guards
|
|
63
|
+
- **Dependency injection for boundaries** -- `fetchFn` is injectable in `gitlab-poller.ts`; required for the GitHub adapter too
|
|
64
|
+
- **Prefer fakes over mocks** -- tests use `vi.fn().mockResolvedValue()` for fetch; maintain this pattern
|
|
65
|
+
- **YAGNI with discipline** -- no registry pattern (premature for 2 providers)
|
|
66
|
+
|
|
67
|
+
**Philosophy conflict:** The backlog example uses `excludeAuthors: "worktrain-*"` suggesting glob matching. The `CLAUDE.md` principle of determinism and the YAML parser's lack of glob support argue for exact string matching only. **Resolution:** exact string match for MVP; glob support is a documented TODO.
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Impact Surface
|
|
72
|
+
|
|
73
|
+
Files that must remain consistent:
|
|
74
|
+
|
|
75
|
+
- `src/trigger/types.ts` -- type shape changes here cascade to the assembler and scheduler
|
|
76
|
+
- `src/trigger/trigger-store.ts` -- `SUPPORTED_PROVIDERS`, `ParsedTriggerRaw.source`, assembly code
|
|
77
|
+
- `src/trigger/polling-scheduler.ts` -- `isPollingTrigger`, `buildWorkflowTrigger`, `doPoll` routing
|
|
78
|
+
- `tests/unit/trigger-store.test.ts` -- tests for `github_issues_poll` and `github_prs_poll` providers
|
|
79
|
+
- `tests/unit/gitlab-poller.test.ts` -- template; existing tests must continue to pass
|
|
80
|
+
|
|
81
|
+
No other files outside `src/trigger/` read `pollingSource` today.
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## Candidates
|
|
86
|
+
|
|
87
|
+
### Candidate A: Minimal extension -- bare union type, if/else in scheduler
|
|
88
|
+
|
|
89
|
+
**Summary:** Add `GitHubPollingSource` to `types.ts` as a bare union (`pollingSource?: GitLabPollingSource | GitHubPollingSource`), add new providers to `SUPPORTED_PROVIDERS`, extend the `source:` parser, add `if/else` branches in `PollingScheduler.doPoll`.
|
|
90
|
+
|
|
91
|
+
**Tensions resolved:** Zero migration cost. No changes to existing narrowing code.
|
|
92
|
+
|
|
93
|
+
**Tensions accepted:** The discriminated union TODO is ignored. TypeScript cannot prevent a `GitLabPollingSource` being used with a `github_issues_poll` trigger at the assembled type level. Illegal states remain representable.
|
|
94
|
+
|
|
95
|
+
**Boundary solved at:** `polling-scheduler.ts` routing -- additive `if/else` only.
|
|
96
|
+
|
|
97
|
+
**Why this boundary:** Minimum change surface.
|
|
98
|
+
|
|
99
|
+
**Failure mode:** A future refactor could accidentally call `pollGitLabMRs` with a `GitHubPollingSource` because the compiler cannot distinguish them without the tag. Silent wrong behavior, not a compile error.
|
|
100
|
+
|
|
101
|
+
**Repo-pattern relationship:** Adapts existing pattern but ignores the explicit codebase TODO for discriminated union.
|
|
102
|
+
|
|
103
|
+
**Gains:** Zero migration cost, zero risk of breaking existing code.
|
|
104
|
+
|
|
105
|
+
**Losses:** Correctness guarantee at the type level. The codebase's own comment requests this migration.
|
|
106
|
+
|
|
107
|
+
**Impact surface:** `polling-scheduler.ts` only (additive). `types.ts` additive.
|
|
108
|
+
|
|
109
|
+
**Scope judgment:** Correct scope, but incomplete honoring of stated invariants.
|
|
110
|
+
|
|
111
|
+
**Philosophy fit:** Honors YAGNI. Violates "Make illegal states unrepresentable".
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
### Candidate B: Tagged union migration (RECOMMENDED)
|
|
116
|
+
|
|
117
|
+
**Summary:** Migrate `TriggerDefinition.pollingSource` to a discriminated union tagged by `provider`:
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
export type PollingSource =
|
|
121
|
+
| (GitLabPollingSource & { readonly provider: 'gitlab_poll' })
|
|
122
|
+
| (GitHubPollingSource & { readonly provider: 'github_issues_poll' })
|
|
123
|
+
| (GitHubPollingSource & { readonly provider: 'github_prs_poll' });
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`trigger-store.ts` assembler produces the tagged object (adds `provider` field to the assembled source). `polling-scheduler.ts` uses `switch(trigger.pollingSource.provider)` or typed `if/else` on `provider` to narrow. `github-poller.ts` is a pure function matching the GitLab adapter signature.
|
|
127
|
+
|
|
128
|
+
**Tensions resolved:** Discriminated union TODO fulfilled. Illegal states unrepresentable at the assembled type level. `switch` on `provider` is compiler-enforced exhaustive.
|
|
129
|
+
|
|
130
|
+
**Tensions accepted:** Small migration: ~20 lines of changes in existing files. The raw `ParsedTriggerRaw.source` type in `trigger-store.ts` is still an untagged bag of optional fields (the discrimination only applies after assembly, not during parsing).
|
|
131
|
+
|
|
132
|
+
**Boundary solved at:** `types.ts` (new union), `trigger-store.ts` (tagged assembly), `polling-scheduler.ts` (switch routing).
|
|
133
|
+
|
|
134
|
+
**Why this boundary is best fit:** The `types.ts` comment explicitly requests this migration at adapter #2. This IS adapter #2. Not doing it defers a known debt item.
|
|
135
|
+
|
|
136
|
+
**Failure mode:** If a future consumer outside `src/trigger/` reads `pollingSource` and was not updated during migration. Verified: no such consumer exists today.
|
|
137
|
+
|
|
138
|
+
**Repo-pattern relationship:** Directly follows what the codebase's own TODO comment requests. Mirrors `TriggerId` branded string: same philosophy of making types carry guarantees.
|
|
139
|
+
|
|
140
|
+
**Gains:** Compile-time guarantee that GitLab source never reaches the GitHub adapter. Exhaustive `switch` on provider. Clean type-level documentation of which sources exist.
|
|
141
|
+
|
|
142
|
+
**Losses:** ~20 lines of existing code must be updated. The `pollingSource` field shape changes.
|
|
143
|
+
|
|
144
|
+
**Impact surface:** `trigger-store.ts` assembler, `polling-scheduler.ts` narrowing, `types.ts`. No external consumers.
|
|
145
|
+
|
|
146
|
+
**Scope judgment:** Best-fit -- the TODO explicitly scopes this to adapter #2.
|
|
147
|
+
|
|
148
|
+
**Philosophy fit:** Honors "Make illegal states unrepresentable", "Type safety as first line of defense", "Exhaustiveness everywhere". Minor YAGNI tension (resolved by the existing TODO).
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
### Candidate C: Generic `PollingAdapter` interface with registry
|
|
153
|
+
|
|
154
|
+
**Summary:** Define `interface PollingAdapter<TItem, TSource>` with `poll(source: TSource, since: string, fetchFn?) => Result<TItem[], error>`. A registry `Map<string, PollingAdapter<unknown, unknown>>` holds all adapters. The scheduler looks up by `trigger.provider`. Adding a new provider = registering a new adapter; no scheduler changes.
|
|
155
|
+
|
|
156
|
+
**Tensions resolved:** Open/closed: adding providers requires zero scheduler changes. Perfect separation of concerns.
|
|
157
|
+
|
|
158
|
+
**Tensions accepted:** The registry carries `unknown` typed adapters. The scheduler must cast when building `WorkflowTrigger` context -- type safety lost at the registry boundary. Runtime invariant, not compile-time.
|
|
159
|
+
|
|
160
|
+
**Boundary solved at:** New `adapter-registry.ts`. Scheduler depends on registry, not specific adapters.
|
|
161
|
+
|
|
162
|
+
**Why this boundary:** Maximum extensibility for future providers.
|
|
163
|
+
|
|
164
|
+
**Failure mode:** A misregistered adapter (wrong source type registered under wrong provider key) silently produces wrong context at dispatch time. No compile-time catch.
|
|
165
|
+
|
|
166
|
+
**Repo-pattern relationship:** Departs from existing patterns. Codebase has no registry or DI container for adapters. Introduces a new abstraction not justified by existing use.
|
|
167
|
+
|
|
168
|
+
**Gains:** Maximum extensibility (Jira, Linear, Sentry adapters add zero scheduler code).
|
|
169
|
+
|
|
170
|
+
**Losses:** Type safety at the registry boundary. Complexity for 2 providers. Violates YAGNI and the explicit CLAUDE.md warning against speculative abstractions.
|
|
171
|
+
|
|
172
|
+
**Impact surface:** New `adapter-registry.ts`, changes to `polling-scheduler.ts`, new abstract type in `types.ts`.
|
|
173
|
+
|
|
174
|
+
**Scope judgment:** Too broad -- 2 providers do not justify a registry.
|
|
175
|
+
|
|
176
|
+
**Philosophy fit:** Violates YAGNI, violates "Type safety as first line of defense". Honors open/closed but prematurely.
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## Comparison and Recommendation
|
|
181
|
+
|
|
182
|
+
**Recommendation: Candidate B (tagged union migration)**
|
|
183
|
+
|
|
184
|
+
| Dimension | A (minimal) | B (tagged union) | C (registry) |
|
|
185
|
+
|---|---|---|---|
|
|
186
|
+
| Illegal states representable | Yes (bug risk) | No (compile-time) | Partially (cast gap) |
|
|
187
|
+
| Migration cost | Zero | ~20 lines | High |
|
|
188
|
+
| Exhaustive narrowing | No | Yes | No |
|
|
189
|
+
| YAGNI | Honors | Minor (TODO overrides) | Violates |
|
|
190
|
+
| Consistent with TODO | No | Yes | No |
|
|
191
|
+
| Future extensibility | If/else grows | Union grows | Registry extends |
|
|
192
|
+
|
|
193
|
+
Candidate B is the correct choice for three reasons:
|
|
194
|
+
|
|
195
|
+
1. **The TODO is a commitment.** `types.ts` already says "TODO(follow-up): migrate to discriminated union at adapter #2." This is adapter #2. Skipping this is explicitly deferring committed technical debt.
|
|
196
|
+
2. **The migration cost is bounded.** `pollingSource` is only read inside `src/trigger/`. No external consumers. The ~20 lines of changes are confined to three files.
|
|
197
|
+
3. **The gain is real.** The `switch(pollingSource.provider)` pattern gives the TypeScript compiler full narrowing -- it is impossible to call `pollGitLabMRs` with a `GitHubPollingSource` after this change. Candidate A has no equivalent safety.
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## Self-Critique
|
|
202
|
+
|
|
203
|
+
**Strongest argument against B:** The raw `ParsedTriggerRaw.source` type in `trigger-store.ts` is still an untagged bag of optional fields -- both `projectId` (GitLab) and `repo` (GitHub) fields exist in the same raw type. The discriminated union only applies after assembly. So the "illegal states unrepresentable" benefit does NOT apply during YAML parsing -- only after. Candidate A has identical parse-time behavior. The union migration adds safety only at dispatch time.
|
|
204
|
+
|
|
205
|
+
Counter-counter: dispatch time is where safety matters. The assembler is the parser boundary. After the assembler, the system should be able to trust the types -- that's where the discriminated union matters.
|
|
206
|
+
|
|
207
|
+
**Narrower option that almost works:** Candidate A with a runtime assertion in the scheduler: `assert(trigger.provider === 'gitlab_poll')` before calling `pollGitLabMRs`. But TypeScript structural typing means both source types share enough optional fields that the assert is not enforced by the compiler -- it's a runtime check, not a compile-time one.
|
|
208
|
+
|
|
209
|
+
**Broader option that might be justified:** Candidate C (registry), but only if 4+ providers are added within 6 months. At 2 providers, the registry is premature. Evidence required: 3+ new adapter types confirmed in the near-term roadmap.
|
|
210
|
+
|
|
211
|
+
**Pivot conditions:**
|
|
212
|
+
- If `pollingSource` is used outside `src/trigger/` in a future PR (expands migration scope)
|
|
213
|
+
- If the `excludeAuthors` exact-string match is insufficient for the WorkTrain bot naming convention (requires glob support)
|
|
214
|
+
- If the GitHub rate limit at 5000 req/hour proves insufficient for high-volume repos (requires backoff strategy)
|
|
215
|
+
|
|
216
|
+
---
|
|
217
|
+
|
|
218
|
+
## Open Questions for the Main Agent
|
|
219
|
+
|
|
220
|
+
1. **`excludeAuthors` matching**: Should it be exact string match (e.g., `worktrain-bot`) or glob pattern (e.g., `worktrain-*`)? The backlog example uses `worktrain-*`. Exact match is simpler and safer for MVP.
|
|
221
|
+
|
|
222
|
+
2. **`repo` field format**: Should it be `owner/repo` (single string, split at `/`) or two separate fields `owner` and `repo`? Single string `owner/repo` is consistent with how GitHub URLs work and matches the GitLab `projectId: namespace/project` pattern.
|
|
223
|
+
|
|
224
|
+
3. **Rate limit skip**: When `X-RateLimit-Remaining < 100`, should this be a `GitHubPollError` kind or a silent skip (log only)? The prompt says "skip the current cycle and log a warning" -- so log only, no error return. Confirm this matches desired behavior.
|
|
225
|
+
|
|
226
|
+
4. **`notLabels` documentation**: The pagination limitation means `notLabels` can silently miss items on busy repos. Should this be documented in the config schema comment, or is it acceptable as an implicit limitation?
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# GitHub Polling Adapter: Design Review Findings
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-04-15
|
|
4
|
+
**Design reviewed:** Candidate B -- tagged `PollingSource` discriminated union
|
|
5
|
+
**Status:** Complete
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Tradeoff Review
|
|
10
|
+
|
|
11
|
+
### Tradeoff 1: Parse-time raw type is still untagged (accepted)
|
|
12
|
+
|
|
13
|
+
The `ParsedTriggerRaw.source` bag holds both `projectId` (GitLab) and `repo` (GitHub) as optional fields. Discrimination only applies after assembly. This is valid: the assembler validates required fields with explicit `missing_field` errors. A `github_issues_poll` trigger with `projectId` instead of `repo` will fail at assembly with a clear error.
|
|
14
|
+
|
|
15
|
+
**Hidden assumption verified:** The assembler must explicitly validate `repo` as required for `github_issues_poll`/`github_prs_poll` (not inherited from GitLab path). Must be in the implementation.
|
|
16
|
+
|
|
17
|
+
### Tradeoff 2: `excludeAuthors` exact string match (accepted)
|
|
18
|
+
|
|
19
|
+
Valid for a single controlled bot account. Breaks if the bot naming convention uses unpredictable suffixes. Mitigated by: prominent warning in `GitHubPollingSource` comment, TODO for glob support.
|
|
20
|
+
|
|
21
|
+
### Tradeoff 3: 100 items per page, no pagination (accepted)
|
|
22
|
+
|
|
23
|
+
Issues API has native `since` filter -- pagination almost never needed. PRs API lacks `since` -- all open PRs fetched and filtered client-side. Burst risk (>100 PRs in one interval) is low for typical repos. Must be documented.
|
|
24
|
+
|
|
25
|
+
### Tradeoff 4: GitHub PRs client-side `updated_at` filter (accepted)
|
|
26
|
+
|
|
27
|
+
Higher API cost per cycle (fetch up to 100 PRs vs. only updated ones). Within rate limit budget at normal poll intervals (42 req/hour for PRs at 5-min interval). Rate limit check guards the threshold.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Failure Mode Review
|
|
32
|
+
|
|
33
|
+
### RED: Self-loop if `excludeAuthors` not configured
|
|
34
|
+
|
|
35
|
+
**Risk level:** HIGH. An unconfigured `excludeAuthors` creates an infinite loop: WorkTrain PR -> poll picks it up -> workflow dispatched -> another PR -> repeat. Cascading effects: rate limit exhaustion, unbounded sessions, potential cost impact.
|
|
36
|
+
|
|
37
|
+
**Current mitigation:** None (it's optional config). User must explicitly set it.
|
|
38
|
+
|
|
39
|
+
**Required mitigation:**
|
|
40
|
+
- `GitHubPollingSource.excludeAuthors` comment must contain: "IMPORTANT: include your WorkTrain bot account login here to prevent infinite self-review loops."
|
|
41
|
+
- `polling-scheduler.ts` dispatch path comment must repeat the warning.
|
|
42
|
+
- A default auto-detection TODO (WorkTrain could know its own GitHub login) should be filed.
|
|
43
|
+
|
|
44
|
+
### ORANGE: Rate limit skip has no alerting beyond log line
|
|
45
|
+
|
|
46
|
+
**Risk level:** Medium. When `X-RateLimit-Remaining < 100`, the cycle is silently skipped with only a console warning. Users who don't monitor logs may not notice the adapter has stopped polling.
|
|
47
|
+
|
|
48
|
+
**Current mitigation:** Log warning per skipped cycle.
|
|
49
|
+
|
|
50
|
+
**Recommended mitigation:** No code change needed for MVP, but the log message should include the `X-RateLimit-Reset` timestamp so users know when polling will resume.
|
|
51
|
+
|
|
52
|
+
### YELLOW: >100 PRs in one poll interval causes silent miss
|
|
53
|
+
|
|
54
|
+
**Risk level:** Low for typical repos, medium for dependency-bot-heavy repos.
|
|
55
|
+
|
|
56
|
+
**Mitigation:** Document in `GitHubPollingSource.pollIntervalSeconds` comment. No code change.
|
|
57
|
+
|
|
58
|
+
### YELLOW: `excludeAuthors` exact match may be insufficient for numbered bot accounts
|
|
59
|
+
|
|
60
|
+
**Risk level:** Low -- most bot accounts have stable names.
|
|
61
|
+
|
|
62
|
+
**Mitigation:** TODO comment for glob support. No code change.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Runner-Up / Simpler Alternative Review
|
|
67
|
+
|
|
68
|
+
**Candidate A (bare union + if/else):** Identified one borrow -- using `trigger.provider` for the dispatch switch is valid, but inside the switch arm TypeScript still needs the tag on `pollingSource` to narrow from `GitLabPollingSource | GitHubPollingSource` to the specific type. The hybrid (trigger.provider switch + untagged union) would require unsafe casts. Not worth pursuing.
|
|
69
|
+
|
|
70
|
+
**Merged `github_poll` provider:** Rejected. Issues and PRs have different endpoints, different shapes, different filter semantics. Merging adds conditional logic to the adapter. Two single-purpose adapters are cleaner.
|
|
71
|
+
|
|
72
|
+
**Simpler tagged union (add tag to source, but skip updating `isPollingTrigger` guard):** Already analyzed -- `isPollingTrigger` works unchanged when `pollingSource` becomes `PollingSource`. No simplification available.
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## Philosophy Alignment
|
|
77
|
+
|
|
78
|
+
| Principle | Status |
|
|
79
|
+
|---|---|
|
|
80
|
+
| Errors are data | Satisfied -- Result<T,E> everywhere |
|
|
81
|
+
| Immutability by default | Satisfied -- all fields readonly |
|
|
82
|
+
| Make illegal states unrepresentable | Satisfied -- tagged union |
|
|
83
|
+
| Type safety as first line of defense | Satisfied -- type guards at boundary |
|
|
84
|
+
| Validate at boundaries, trust inside | Satisfied -- assembler validates |
|
|
85
|
+
| Dependency injection for boundaries | Satisfied -- fetchFn injectable |
|
|
86
|
+
| Prefer fakes over mocks | Satisfied -- vi.fn() fake pattern |
|
|
87
|
+
| Document why not what | Satisfied -- invariant comments required |
|
|
88
|
+
| YAGNI | Minor tension -- overridden by existing TODO |
|
|
89
|
+
| Determinism over cleverness | Minor tension -- excludeAuthors exact match acceptable |
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Findings
|
|
94
|
+
|
|
95
|
+
### RED
|
|
96
|
+
|
|
97
|
+
**Self-loop risk is not mitigated by default.** `excludeAuthors` is optional config with no default. A WorkTrain PR polling trigger with no `excludeAuthors` creates an infinite loop. The design must include prominent warnings in the `GitHubPollingSource` type comment and in the `polling-scheduler.ts` dispatch path comment.
|
|
98
|
+
|
|
99
|
+
### ORANGE
|
|
100
|
+
|
|
101
|
+
**Rate limit skip log message should include reset time.** When `X-RateLimit-Remaining < 100`, include `X-RateLimit-Reset` (Unix timestamp) in the log message so users know when polling resumes without digging into GitHub's documentation.
|
|
102
|
+
|
|
103
|
+
### YELLOW
|
|
104
|
+
|
|
105
|
+
**Both pagination and client-side filtering limitations must be documented in the type comment.** Not in code comments only -- in the `GitHubPollingSource` interface comment where users read it when configuring triggers.
|
|
106
|
+
|
|
107
|
+
### YELLOW
|
|
108
|
+
|
|
109
|
+
**`excludeAuthors` exact-match limitation must have a TODO for glob support.** The backlog example uses `worktrain-*` which implies glob. The implementation note should clarify that this is exact match and reference the backlog entry.
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Recommended Revisions
|
|
114
|
+
|
|
115
|
+
1. **Add self-loop warning to `GitHubPollingSource.excludeAuthors` field comment** (required, not optional). Example text: "IMPORTANT: always include your WorkTrain bot account login here (e.g., `worktrain-bot`). Omitting this causes an infinite self-review loop where WorkTrain reviews its own PRs."
|
|
116
|
+
|
|
117
|
+
2. **Include `X-RateLimit-Reset` in the rate limit skip log message.** Change: `console.warn('[GH] Rate limit low: remaining=${remaining}')` to `console.warn('[GH] Rate limit low: remaining=${remaining}, resets at ${new Date(resetTs * 1000).toISOString()}')`.
|
|
118
|
+
|
|
119
|
+
3. **Document pagination limitation and client-side filter in `GitHubPollingSource` comments** -- one sentence each.
|
|
120
|
+
|
|
121
|
+
4. **Add TODO for glob support in `excludeAuthors` comment** -- "TODO: exact string match only. Glob support (e.g., worktrain-*) planned but not implemented."
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## Residual Concerns
|
|
126
|
+
|
|
127
|
+
- **`github_prs_poll` redundancy with `github_issues_poll`:** GitHub Issues API returns PRs as well (a PR is also an issue). Using `github_issues_poll` with `labelFilter: my-label` could accidentally pick up PRs. Users should be aware that `state=open` on the issues endpoint includes open PRs. The `github_prs_poll` adapter uses the separate `/repos/:owner/:repo/pulls` endpoint which is PR-only. Both adapters should document this distinction.
|
|
128
|
+
|
|
129
|
+
- **Authentication:** Only personal access tokens are supported (same as GitLab). GitHub App tokens and fine-grained PATs are not addressed. This is an accepted MVP limitation.
|
|
130
|
+
|
|
131
|
+
- **`filter.notLabels` naming:** The backlog uses `filter.notLabels` as a nested object. The implementation uses `notLabels` as a top-level field on `GitHubPollingSource` for simplicity (consistent with how `events` and `labelFilter` are flat fields). No structural concern but naming should be consistent with any future filter fields.
|