@juzser/blacksmith 0.1.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/.claude/agents/auditor.md +146 -0
- package/.claude/agents/coder.md +164 -0
- package/.claude/agents/grader.md +110 -0
- package/.claude/agents/merger.md +107 -0
- package/.claude/agents/planner.md +92 -0
- package/.claude/agents/researcher.md +105 -0
- package/.claude/agents/reviewer.md +171 -0
- package/.claude/agents/scribe.md +72 -0
- package/.claude/agents/security-reviewer.md +130 -0
- package/.claude/agents/spec-reviewer.md +215 -0
- package/.claude/agents/tester.md +121 -0
- package/.claude/agents/uiux.md +126 -0
- package/.claude/agents/verifier.md +103 -0
- package/.claude/agents/wave-runner.md +74 -0
- package/LICENSE +21 -0
- package/README.md +389 -0
- package/factory/orchestrator/dist/actors.js +46 -0
- package/factory/orchestrator/dist/agents-registry.js +308 -0
- package/factory/orchestrator/dist/args.js +84 -0
- package/factory/orchestrator/dist/artifacts.js +111 -0
- package/factory/orchestrator/dist/attribution.js +160 -0
- package/factory/orchestrator/dist/audit.js +812 -0
- package/factory/orchestrator/dist/autonomy.js +151 -0
- package/factory/orchestrator/dist/budgetAlarm.js +366 -0
- package/factory/orchestrator/dist/budgets.js +196 -0
- package/factory/orchestrator/dist/claims.js +633 -0
- package/factory/orchestrator/dist/cli.js +3185 -0
- package/factory/orchestrator/dist/commit.js +152 -0
- package/factory/orchestrator/dist/coverage.js +188 -0
- package/factory/orchestrator/dist/crossFinding.js +479 -0
- package/factory/orchestrator/dist/crosscheck.js +396 -0
- package/factory/orchestrator/dist/daemon.js +707 -0
- package/factory/orchestrator/dist/db/projector.js +1317 -0
- package/factory/orchestrator/dist/db/queries.js +1714 -0
- package/factory/orchestrator/dist/db/schema.js +305 -0
- package/factory/orchestrator/dist/delegation.js +400 -0
- package/factory/orchestrator/dist/diffstat.js +164 -0
- package/factory/orchestrator/dist/dispatchAudit.js +275 -0
- package/factory/orchestrator/dist/dotenv.js +72 -0
- package/factory/orchestrator/dist/effort.js +202 -0
- package/factory/orchestrator/dist/epic.js +1018 -0
- package/factory/orchestrator/dist/epicWidth.js +238 -0
- package/factory/orchestrator/dist/errorIssues.js +249 -0
- package/factory/orchestrator/dist/errors.js +11 -0
- package/factory/orchestrator/dist/escalation.js +500 -0
- package/factory/orchestrator/dist/events.js +1015 -0
- package/factory/orchestrator/dist/findingAge.js +91 -0
- package/factory/orchestrator/dist/findingContext.js +103 -0
- package/factory/orchestrator/dist/findings.js +1156 -0
- package/factory/orchestrator/dist/gate.js +911 -0
- package/factory/orchestrator/dist/gh.js +146 -0
- package/factory/orchestrator/dist/git.js +87 -0
- package/factory/orchestrator/dist/goalCheck.js +380 -0
- package/factory/orchestrator/dist/graph.js +61 -0
- package/factory/orchestrator/dist/hookDecision.js +106 -0
- package/factory/orchestrator/dist/immutability.js +171 -0
- package/factory/orchestrator/dist/impact.js +280 -0
- package/factory/orchestrator/dist/integration.js +141 -0
- package/factory/orchestrator/dist/judgePreflight.js +155 -0
- package/factory/orchestrator/dist/judges.js +310 -0
- package/factory/orchestrator/dist/lessonAudit.js +399 -0
- package/factory/orchestrator/dist/lessons.js +1329 -0
- package/factory/orchestrator/dist/mcp.js +555 -0
- package/factory/orchestrator/dist/paths.js +82 -0
- package/factory/orchestrator/dist/plan.js +502 -0
- package/factory/orchestrator/dist/planQuorum.js +421 -0
- package/factory/orchestrator/dist/policy.js +1308 -0
- package/factory/orchestrator/dist/policyHook.js +28 -0
- package/factory/orchestrator/dist/preconditions.js +47 -0
- package/factory/orchestrator/dist/projects.js +134 -0
- package/factory/orchestrator/dist/prompts.js +57 -0
- package/factory/orchestrator/dist/provenance.js +201 -0
- package/factory/orchestrator/dist/providers/api-transport.js +117 -0
- package/factory/orchestrator/dist/providers/cli-transport.js +208 -0
- package/factory/orchestrator/dist/providers/index.js +54 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +152 -0
- package/factory/orchestrator/dist/providers/types.js +49 -0
- package/factory/orchestrator/dist/queue.js +263 -0
- package/factory/orchestrator/dist/quorum.js +347 -0
- package/factory/orchestrator/dist/quorumEscalations.js +132 -0
- package/factory/orchestrator/dist/results.js +65 -0
- package/factory/orchestrator/dist/roadmap.js +437 -0
- package/factory/orchestrator/dist/runtime.js +86 -0
- package/factory/orchestrator/dist/sameMistakeKpi.js +243 -0
- package/factory/orchestrator/dist/sandbox.js +165 -0
- package/factory/orchestrator/dist/scaffold.js +373 -0
- package/factory/orchestrator/dist/scheduler.js +408 -0
- package/factory/orchestrator/dist/schemas.js +195 -0
- package/factory/orchestrator/dist/security.js +222 -0
- package/factory/orchestrator/dist/severity.js +293 -0
- package/factory/orchestrator/dist/spec.js +376 -0
- package/factory/orchestrator/dist/specChange.js +497 -0
- package/factory/orchestrator/dist/stack.js +324 -0
- package/factory/orchestrator/dist/symbols.js +966 -0
- package/factory/orchestrator/dist/taskEvents.js +399 -0
- package/factory/orchestrator/dist/taskId.js +126 -0
- package/factory/orchestrator/dist/taxonomy.js +113 -0
- package/factory/orchestrator/dist/testSelect.js +129 -0
- package/factory/orchestrator/dist/testerAudit.js +264 -0
- package/factory/orchestrator/dist/testgate.js +171 -0
- package/factory/orchestrator/dist/usage.js +874 -0
- package/factory/orchestrator/dist/waivers.js +228 -0
- package/factory/orchestrator/dist/waveBudget.js +223 -0
- package/factory/orchestrator/dist/waveConcurrency.js +250 -0
- package/factory/orchestrator/dist/waveNext.js +210 -0
- package/factory/orchestrator/dist/waveSchedule.js +146 -0
- package/factory/orchestrator/dist/worktree.js +207 -0
- package/factory/orchestrator/drizzle/0000_broken_rockslide.sql +183 -0
- package/factory/orchestrator/drizzle/0001_luxuriant_zaladane.sql +1 -0
- package/factory/orchestrator/drizzle/0002_yellow_wild_pack.sql +8 -0
- package/factory/orchestrator/drizzle/0003_damp_riptide.sql +12 -0
- package/factory/orchestrator/drizzle/0004_broad_lady_vermin.sql +1 -0
- package/factory/orchestrator/drizzle/0005_romantic_jocasta.sql +2 -0
- package/factory/orchestrator/drizzle/0006_bent_madelyne_pryor.sql +16 -0
- package/factory/orchestrator/drizzle/0007_overjoyed_goliath.sql +1 -0
- package/factory/orchestrator/drizzle/0008_smart_iron_patriot.sql +2 -0
- package/factory/orchestrator/drizzle/0009_agents_epic_id.sql +2 -0
- package/factory/orchestrator/drizzle/0010_milestone_kind.sql +1 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +83 -0
- package/factory/policies/budgets.yml +262 -0
- package/factory/policies/crosscheck.yml +308 -0
- package/factory/policies/delegation.yml +44 -0
- package/factory/policies/effort.yml +142 -0
- package/factory/policies/guardrails.yml +306 -0
- package/factory/policies/lessons.md +239 -0
- package/factory/policies/scheduler.yml +90 -0
- package/factory/policies/sensitive-paths.yml +148 -0
- package/factory/policies/severity.yml +83 -0
- package/factory/policies/stack.yml +60 -0
- package/factory/policies/taxonomy.yml +340 -0
- package/factory/policies/worktree.yml +95 -0
- package/factory/scaffold/base/.github/workflows/ci.yml +34 -0
- package/factory/scaffold/base/.gitignore.tmpl +11 -0
- package/factory/scaffold/base/AGENTS.md +33 -0
- package/factory/scaffold/base/README.md +25 -0
- package/factory/scaffold/base/biome.json.tmpl +30 -0
- package/factory/scaffold/base/package.json +25 -0
- package/factory/scaffold/base/pnpm-workspace.yaml.tmpl +16 -0
- package/factory/scaffold/base/src/index.ts +4 -0
- package/factory/scaffold/base/test/index.test.ts +8 -0
- package/factory/scaffold/base/tsconfig.json +20 -0
- package/factory/scaffold/base/tsconfig.test.json +9 -0
- package/factory/scaffold/base/vitest.config.ts +29 -0
- package/factory/scaffold/mcp/mcp.manifest.json +22 -0
- package/factory/scaffold/mcp/package.fragment.json +9 -0
- package/factory/scaffold/mcp/src/mcp/README.md +63 -0
- package/factory/scaffold/mcp/src/mcp/guard.ts +110 -0
- package/factory/scaffold/mcp/src/mcp/redact.ts +69 -0
- package/factory/scaffold/mcp/src/mcp/server.ts +55 -0
- package/factory/scaffold/mcp/src/mcp/tools/health.ts +60 -0
- package/factory/scaffold/mcp/test/mcp/guard.test.ts +108 -0
- package/factory/scaffold/mcp/test/mcp/redact.test.ts +61 -0
- package/factory/scaffold/mcp/test/mcp/surface.test.ts +100 -0
- package/factory/scaffold/ui/index.html +12 -0
- package/factory/scaffold/ui/package.fragment.json +13 -0
- package/factory/scaffold/ui/src/App.vue +10 -0
- package/factory/scaffold/ui/src/main.ts +5 -0
- package/factory/scaffold/ui/src/shims.d.ts +11 -0
- package/factory/scaffold/ui/vite.config.ts +20 -0
- package/factory/scaffold/ui-tailwind/package.fragment.json +6 -0
- package/factory/scaffold/ui-tailwind/vite.config.ts +26 -0
- package/factory/specs/schema/event.schema.json +82 -0
- package/factory/specs/schema/finding-evidence.schema.json +45 -0
- package/factory/specs/schema/finding.schema.json +115 -0
- package/factory/specs/schema/grader-verdict.schema.json +50 -0
- package/factory/specs/schema/judge-verdict.schema.json +19 -0
- package/factory/specs/schema/lesson.schema.json +85 -0
- package/factory/specs/schema/mcp-manifest.schema.json +230 -0
- package/factory/specs/schema/result.schema.json +81 -0
- package/factory/specs/schema/spec-change-request.schema.json +46 -0
- package/factory/specs/schema/task-spec.schema.json +134 -0
- package/package.json +98 -0
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
// Phase 10's background watcher (`smith daemon`). It closes the one gap the
|
|
2
|
+
// roadmap still names: knowing what the factory needs required an operator to
|
|
3
|
+
// keep a session open and re-run `smith status` by hand.
|
|
4
|
+
//
|
|
5
|
+
// What it is NOT is the more tempting thing. This daemon never dispatches an
|
|
6
|
+
// agent, never merges, never writes to a worktree; scheduler.ts says why in
|
|
7
|
+
// its own words ("Never dispatches an agent itself — architecture §12"), and a
|
|
8
|
+
// process that survives the operator's terminal is the last place to relax
|
|
9
|
+
// that. Its whole output is a list of findings and one refreshed projection.
|
|
10
|
+
//
|
|
11
|
+
// Everything it reports is derived from the event log by folds this repo
|
|
12
|
+
// already owns — budgetAlarm.ts for spend, agents-registry.ts for stalls,
|
|
13
|
+
// scheduler.ts for work that is due. Nothing here re-derives them, so the
|
|
14
|
+
// daemon and `smith status` can never disagree.
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { DEFAULT_STALE_HOURS, detectStale, foldAgents, } from './agents-registry.js';
|
|
18
|
+
import { admitProposals, } from './autonomy.js';
|
|
19
|
+
import { checkBudgetAlarm } from './budgetAlarm.js';
|
|
20
|
+
import { loadBudgetPolicy } from './budgets.js';
|
|
21
|
+
import { loadCrosscheckPolicy } from './crosscheck.js';
|
|
22
|
+
import { apply, foldTasks, rebuild } from './db/projector.js';
|
|
23
|
+
import { summariseEpicWidth, UNMEASURED_HINT } from './epicWidth.js';
|
|
24
|
+
import { SmithError } from './errors.js';
|
|
25
|
+
import { listSessionIds, mergeSessionLogs, parseEventId, readEvents, } from './events.js';
|
|
26
|
+
import { ageFindings, memoryOf } from './findingAge.js';
|
|
27
|
+
import { STATE_DAEMON_DIR, STATE_DB_PATH, STATE_EVENTS_DIR } from './paths.js';
|
|
28
|
+
import { unwatchedProjects } from './projects.js';
|
|
29
|
+
import { computeProposals, loadSchedulerPolicy, } from './scheduler.js';
|
|
30
|
+
import { foldSpecChanges } from './specChange.js';
|
|
31
|
+
export class DaemonError extends SmithError {
|
|
32
|
+
}
|
|
33
|
+
/** A session log's first event, and the only one allowed to name another session. */
|
|
34
|
+
const ROOT_EVENT_TYPE = 'session-start';
|
|
35
|
+
const LOCK_FILE = 'daemon.pid';
|
|
36
|
+
const STATUS_FILE = 'status.json';
|
|
37
|
+
const MEMORY_FILE = 'findings.json';
|
|
38
|
+
export const DEFAULT_INTERVAL_SECONDS = 300;
|
|
39
|
+
/**
|
|
40
|
+
* One admission per proposal, positionally — `admitProposals` returns them in
|
|
41
|
+
* the order given, which is the only reason the callers may index alongside
|
|
42
|
+
* their own loop.
|
|
43
|
+
*
|
|
44
|
+
* The claims are folded here rather than passed in because a `RecheckProposal`
|
|
45
|
+
* names a task and no paths: without them the security match sees an opaque id
|
|
46
|
+
* and clears a recheck of `src/auth/session.ts`. They come out of the same
|
|
47
|
+
* `foldTasks` the CLI reads, from the events the caller already holds.
|
|
48
|
+
*/
|
|
49
|
+
function admitFor(proposals, events, lens) {
|
|
50
|
+
if (lens === undefined || proposals.length === 0)
|
|
51
|
+
return proposals.map(() => undefined);
|
|
52
|
+
const claimsByTask = new Map();
|
|
53
|
+
for (const task of foldTasks(events)) {
|
|
54
|
+
if (task.claims && task.claims.length > 0)
|
|
55
|
+
claimsByTask.set(task.taskId, task.claims);
|
|
56
|
+
}
|
|
57
|
+
return admitProposals(proposals, lens.autonomy, {
|
|
58
|
+
securityKeywords: lens.securityKeywords,
|
|
59
|
+
claimsByTask,
|
|
60
|
+
}).map(({ decision, code, reason }) => ({ decision, code, reason }));
|
|
61
|
+
}
|
|
62
|
+
function staleSubject(agent) {
|
|
63
|
+
return agent.taskId ?? agent.epicId ?? agent.agentId ?? agent.id;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Everything one session's lineage says about itself: spend against the epic
|
|
67
|
+
* cap, dispatches nobody can be billed for, agents that never came back, and
|
|
68
|
+
* completed work the recheck policy says is due another look.
|
|
69
|
+
*
|
|
70
|
+
* Pure — `events` is already-read, `now` is threaded in. The disk work is
|
|
71
|
+
* runTick's.
|
|
72
|
+
*/
|
|
73
|
+
export function inspectSession(sessionId, events, opts = {}) {
|
|
74
|
+
const now = opts.now ?? new Date();
|
|
75
|
+
const budgetPolicy = opts.budgetPolicy ?? loadBudgetPolicy();
|
|
76
|
+
const schedulerPolicy = opts.schedulerPolicy ?? loadSchedulerPolicy();
|
|
77
|
+
const staleHours = opts.staleHours ?? DEFAULT_STALE_HOURS;
|
|
78
|
+
const findings = [];
|
|
79
|
+
const budget = checkBudgetAlarm(events, budgetPolicy, { sessionId });
|
|
80
|
+
for (const epic of budget.epics) {
|
|
81
|
+
// `under` is the only status that is an answer rather than a question.
|
|
82
|
+
if (epic.status === 'under')
|
|
83
|
+
continue;
|
|
84
|
+
findings.push({
|
|
85
|
+
kind: 'budget',
|
|
86
|
+
severity: epic.status === 'unverifiable' ? 'info' : 'attention',
|
|
87
|
+
sessionId,
|
|
88
|
+
subject: epic.epicId,
|
|
89
|
+
// The status leads, and the numbers behind it are budgetAlarm's own
|
|
90
|
+
// sentence — two files describing one spend in two registers is how the
|
|
91
|
+
// daemon and `smith budget` start disagreeing.
|
|
92
|
+
detail: `${epic.status}: ${epic.detail}`,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
if (budget.unattributedDispatches > 0) {
|
|
96
|
+
findings.push({
|
|
97
|
+
kind: 'unattributed-spend',
|
|
98
|
+
severity: 'info',
|
|
99
|
+
sessionId,
|
|
100
|
+
subject: `${budget.unattributedDispatches} dispatch(es)`,
|
|
101
|
+
detail: `${budget.unattributedDispatches} dispatch(es) (${budget.unattributedRoles.join(', ')}) ` +
|
|
102
|
+
'name no epic, so their tokens are in no cap. Attribute them or accept that the ' +
|
|
103
|
+
'session total is a floor.',
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
for (const agent of detectStale(foldAgents(events), now.toISOString(), staleHours)) {
|
|
107
|
+
findings.push({
|
|
108
|
+
kind: 'stale-agent',
|
|
109
|
+
severity: 'attention',
|
|
110
|
+
sessionId,
|
|
111
|
+
subject: staleSubject(agent),
|
|
112
|
+
detail: `${agent.agentRole} (${agent.provider}/${agent.modelTier}) has been live for ` +
|
|
113
|
+
`${agent.liveHours.toFixed(1)}h with no result, error or supersession — past the ` +
|
|
114
|
+
`${staleHours}h threshold. Dispatched ${agent.dispatchedAt}.`,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
// A proposal nobody has answered. `blocking` is the worker's own word for
|
|
118
|
+
// "I cannot go further without this", which makes an unanswered blocking
|
|
119
|
+
// proposal a stalled task and not a queue item — the same split
|
|
120
|
+
// research_request draws.
|
|
121
|
+
//
|
|
122
|
+
// Folded, never staleness-checked: `isStale` reads the plan directory and
|
|
123
|
+
// this function is pure. The daemon's claim is that a decision is
|
|
124
|
+
// outstanding, which stays true whether or not a later version has overtaken
|
|
125
|
+
// the diff; `smith plan proposals` is where that second question is answered.
|
|
126
|
+
for (const proposal of foldSpecChanges(events, { status: 'open' })) {
|
|
127
|
+
findings.push({
|
|
128
|
+
kind: 'spec-change',
|
|
129
|
+
severity: proposal.blocking ? 'attention' : 'info',
|
|
130
|
+
sessionId,
|
|
131
|
+
subject: proposal.taskId,
|
|
132
|
+
detail: `${proposal.proposedBy} proposes amending ${proposal.criterionRef}: ${proposal.assumption} ` +
|
|
133
|
+
`(${proposal.severity}, ${proposal.blocking ? 'blocking' : 'non-blocking'}, ` +
|
|
134
|
+
`${proposal.sites.length} site(s)). Answer it with \`smith plan approve ` +
|
|
135
|
+
`${proposal.proposalId}\` or \`smith plan reject ${proposal.proposalId}\`.`,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
const proposals = computeProposals({ events, now, policy: schedulerPolicy });
|
|
139
|
+
const admissions = admitFor(proposals, events, opts.admission);
|
|
140
|
+
for (const [index, proposal] of proposals.entries()) {
|
|
141
|
+
if (proposal.kind !== 'recheck')
|
|
142
|
+
continue;
|
|
143
|
+
const admission = admissions[index];
|
|
144
|
+
findings.push({
|
|
145
|
+
kind: 'recheck',
|
|
146
|
+
severity: 'info',
|
|
147
|
+
sessionId,
|
|
148
|
+
subject: proposal.taskId,
|
|
149
|
+
detail: `Recheck due (${proposal.reasons.join(', ')}): ${proposal.mergeCount} later overlapping ` +
|
|
150
|
+
`merge(s), ${proposal.daysElapsed} day(s) elapsed, confidence ${proposal.confidence}.`,
|
|
151
|
+
...(admission === undefined ? {} : { admission }),
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return findings;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* The findings that belong to the repo and not to any one session: the growth
|
|
158
|
+
* cadence and the dependency-maintenance pass.
|
|
159
|
+
*
|
|
160
|
+
* Split out of inspectSession deliberately. Both are answered from the whole
|
|
161
|
+
* log, and a cadence attributed to a session is a cadence reported once per
|
|
162
|
+
* session — five open sessions, five identical "growth review due" lines, none
|
|
163
|
+
* of which is about that session.
|
|
164
|
+
*/
|
|
165
|
+
export function inspectFactory(events, opts = {}) {
|
|
166
|
+
const now = opts.now ?? new Date();
|
|
167
|
+
const policy = opts.schedulerPolicy ?? loadSchedulerPolicy();
|
|
168
|
+
const findings = [];
|
|
169
|
+
const proposals = computeProposals({
|
|
170
|
+
events,
|
|
171
|
+
now,
|
|
172
|
+
policy,
|
|
173
|
+
...(opts.projectDirs === undefined ? {} : { projectDirs: opts.projectDirs }),
|
|
174
|
+
...(opts.readOutdated === undefined ? {} : { readOutdated: opts.readOutdated }),
|
|
175
|
+
});
|
|
176
|
+
const admissions = admitFor(proposals, events, opts.admission);
|
|
177
|
+
for (const [index, proposal] of proposals.entries()) {
|
|
178
|
+
const admission = admissions[index];
|
|
179
|
+
if (proposal.kind === 'maintenance') {
|
|
180
|
+
findings.push({
|
|
181
|
+
kind: 'maintenance',
|
|
182
|
+
severity: 'info',
|
|
183
|
+
sessionId: null,
|
|
184
|
+
subject: `${proposal.projectDir}: ${proposal.packages.length} package(s)`,
|
|
185
|
+
detail: `${proposal.packages.map((p) => `${p.name} ${p.current}→${p.latest}`).join(', ')} ` +
|
|
186
|
+
`(confidence ${proposal.confidence}, ` +
|
|
187
|
+
`${proposal.autoSchedulable ? 'auto-schedulable' : 'needs an operator'}).`,
|
|
188
|
+
...(admission === undefined ? {} : { admission }),
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
else if (proposal.kind === 'growth-review-due' && events.length > 0) {
|
|
192
|
+
// A cadence needs a log to be a cadence about. `proposeGrowthReview`
|
|
193
|
+
// fires immediately on `lastReviewAt === null`, which is right for a
|
|
194
|
+
// factory that has been working and never reviewed itself and wrong for
|
|
195
|
+
// one that has never done anything at all -- and the second is what an
|
|
196
|
+
// empty log means. The gate belongs on this branch alone: the pass also
|
|
197
|
+
// reads lockfiles and the project register, and neither of those waits
|
|
198
|
+
// on a session to have happened.
|
|
199
|
+
findings.push({
|
|
200
|
+
kind: 'growth-review',
|
|
201
|
+
severity: 'info',
|
|
202
|
+
sessionId: null,
|
|
203
|
+
subject: 'growth-review',
|
|
204
|
+
detail: `A product-growth review is due on the ${proposal.cadenceDays}-day cadence ` +
|
|
205
|
+
`(last: ${proposal.lastReviewAt ?? 'never'}). The planner reads the living spec; ` +
|
|
206
|
+
'this is the trigger, not the scope.',
|
|
207
|
+
...(admission === undefined ? {} : { admission }),
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
// Rechecks are deliberately dropped here: inspectSession already reports
|
|
211
|
+
// each against the session that owns it, and a second copy with no session
|
|
212
|
+
// is a duplicate an operator cannot act on.
|
|
213
|
+
}
|
|
214
|
+
// The claim this repo rests on, watched instead of waited on. `smith epic
|
|
215
|
+
// width` answers it over all of history the moment somebody types it — and
|
|
216
|
+
// nobody types it, which is the gap a watcher exists to close.
|
|
217
|
+
//
|
|
218
|
+
// Of the NEWEST close only, and that restriction is the whole design.
|
|
219
|
+
// Closes are immutable and the fold covers every one of them, so reporting
|
|
220
|
+
// the fold here would raise the same `attention` every tick forever over an
|
|
221
|
+
// epic nobody can go back and fix. An attention count that cannot return to
|
|
222
|
+
// zero is precisely what the FindingSeverity note above forbids: it teaches
|
|
223
|
+
// an operator to stop reading the number, and takes the real alarms with it.
|
|
224
|
+
// The newest close is a statement about now — it clears itself the moment a
|
|
225
|
+
// wide epic closes, and all of the history stays one command away.
|
|
226
|
+
const width = summariseEpicWidth(events);
|
|
227
|
+
const newest = width.epics[0];
|
|
228
|
+
if (newest !== undefined) {
|
|
229
|
+
if (width.serialized.includes(newest.epicId)) {
|
|
230
|
+
// The summary's own rule, reused rather than restated, so the daemon and
|
|
231
|
+
// `smith epic width` cannot come to disagree about what narrow means.
|
|
232
|
+
findings.push({
|
|
233
|
+
kind: 'factory-width',
|
|
234
|
+
severity: 'attention',
|
|
235
|
+
sessionId: null,
|
|
236
|
+
subject: newest.epicId,
|
|
237
|
+
detail: `The last epic this factory closed ran narrow: its widest wave was admitted for ` +
|
|
238
|
+
`${newest.widest.declared} tasks and ${newest.widest.observed} ran ` +
|
|
239
|
+
`(closed ${newest.closedAt}, ${width.serialized.length} of ${width.epics.length} ` +
|
|
240
|
+
'closes read here are narrow). `smith epic width` reads every close back; ' +
|
|
241
|
+
'`smith wave audit --session <id>` reads the waves behind a live one.',
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
else if (width.hint === UNMEASURED_HINT) {
|
|
245
|
+
// Work to schedule, not a fault: nothing here is known to be wrong. What
|
|
246
|
+
// is wrong is that nothing is known, which is a different thing and the
|
|
247
|
+
// reason summariseEpicWidth refuses to let this state exit 0.
|
|
248
|
+
findings.push({
|
|
249
|
+
kind: 'factory-width',
|
|
250
|
+
severity: 'info',
|
|
251
|
+
sessionId: null,
|
|
252
|
+
subject: 'unmeasured',
|
|
253
|
+
detail: `${width.epics.length} epic(s) closed here and none recorded how wide it ran. ` +
|
|
254
|
+
UNMEASURED_HINT,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
// The other half of the sentence this repo is built to answer: it maintains
|
|
259
|
+
// itself AND the projects it built. `--project` has been able to name every
|
|
260
|
+
// repo since the maintenance pass went plural, and nothing noticed an
|
|
261
|
+
// operator who named fewer than all of them -- so a project could drop out
|
|
262
|
+
// of maintenance by being forgotten, which is the same silence, one level
|
|
263
|
+
// down, that repeating the flag was meant to end.
|
|
264
|
+
//
|
|
265
|
+
// Clearable in one flag, which is the test the factory-width note above sets
|
|
266
|
+
// for any new attention. The register deliberately omits a project it cannot
|
|
267
|
+
// find on disk, so nothing here can raise an alarm that has no action.
|
|
268
|
+
for (const ref of unwatchedRepos(opts)) {
|
|
269
|
+
findings.push({
|
|
270
|
+
kind: 'unwatched-project',
|
|
271
|
+
severity: 'attention',
|
|
272
|
+
sessionId: null,
|
|
273
|
+
// The resolved directory, for the same two reasons `maintenance` leads
|
|
274
|
+
// with it: sessionId is null so the subject is the only field that can
|
|
275
|
+
// tell two repos apart, and it is the exact string that clears this.
|
|
276
|
+
subject: ref.dir,
|
|
277
|
+
detail: `${ref.self ? 'This clone' : `\`${ref.name}\`, built by this factory`} is not in ` +
|
|
278
|
+
'this pass, so nothing is reading its lockfile and no maintenance ' +
|
|
279
|
+
`proposal can ever name it. Add \`--project ${ref.dir}\` to \`smith daemon ` +
|
|
280
|
+
'run|start` (and to `smith scheduler run|admit`). `smith projects list` ' +
|
|
281
|
+
'prints the whole flag line.',
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
return findings;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* The repos the register knows and this pass was not pointed at.
|
|
288
|
+
*
|
|
289
|
+
* Wrapped in a catch because the register reads an operator-edited file and
|
|
290
|
+
* this is a watcher: D-21 -- a gate that only reports a fact must never crash
|
|
291
|
+
* over that fact. `factoryProjects` already refuses to throw; this makes an
|
|
292
|
+
* injected reader that does cost nothing but its own finding.
|
|
293
|
+
*/
|
|
294
|
+
function unwatchedRepos(opts) {
|
|
295
|
+
if (opts.readProjects === undefined)
|
|
296
|
+
return [];
|
|
297
|
+
try {
|
|
298
|
+
return unwatchedProjects(opts.readProjects(), opts.projectDirs ?? []);
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
return [];
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
function errorMessage(err) {
|
|
305
|
+
return err instanceof Error ? err.message : String(err);
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* One pass over the state directory.
|
|
309
|
+
*
|
|
310
|
+
* Two rules hold it together. It reads every log ONCE and folds lineages in
|
|
311
|
+
* memory, so a five-session lineage reports one budget alarm rather than five
|
|
312
|
+
* (D-119's argument, read from the other end). And no single unreadable log or
|
|
313
|
+
* failed projection ends the tick: both become findings, because a watchdog
|
|
314
|
+
* that dies on the first corrupt line is a watchdog that is silent exactly
|
|
315
|
+
* when something is wrong.
|
|
316
|
+
*/
|
|
317
|
+
export async function runTick(opts = {}) {
|
|
318
|
+
const now = opts.now ?? new Date();
|
|
319
|
+
const stateDir = opts.stateDir ?? STATE_EVENTS_DIR;
|
|
320
|
+
const findings = [];
|
|
321
|
+
const logs = new Map();
|
|
322
|
+
for (const sessionId of listSessionIds(stateDir)) {
|
|
323
|
+
try {
|
|
324
|
+
logs.set(sessionId, await readEvents(sessionId, { stateDir }));
|
|
325
|
+
}
|
|
326
|
+
catch (err) {
|
|
327
|
+
findings.push({
|
|
328
|
+
kind: 'unreadable-log',
|
|
329
|
+
severity: 'attention',
|
|
330
|
+
sessionId,
|
|
331
|
+
subject: `${sessionId}.jsonl`,
|
|
332
|
+
detail: `The log could not be read: ${errorMessage(err)}`,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
const parentOf = new Map();
|
|
337
|
+
for (const [sessionId, events] of logs) {
|
|
338
|
+
const root = events.find((e) => e.record.event_type === ROOT_EVENT_TYPE);
|
|
339
|
+
const parentEventId = root?.record.causal_parent ?? null;
|
|
340
|
+
let parent = null;
|
|
341
|
+
if (parentEventId !== null) {
|
|
342
|
+
try {
|
|
343
|
+
parent = parseEventId(parentEventId).sessionId;
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
parent = null;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
parentOf.set(sessionId, parent === sessionId ? null : parent);
|
|
350
|
+
}
|
|
351
|
+
const ancestors = new Set();
|
|
352
|
+
for (const parent of parentOf.values()) {
|
|
353
|
+
if (parent !== null && logs.has(parent))
|
|
354
|
+
ancestors.add(parent);
|
|
355
|
+
}
|
|
356
|
+
const leaves = [...logs.keys()].filter((id) => !ancestors.has(id)).sort();
|
|
357
|
+
const lineageOf = (leaf) => {
|
|
358
|
+
const chain = [];
|
|
359
|
+
const seen = new Set();
|
|
360
|
+
let current = leaf;
|
|
361
|
+
while (current !== null && logs.has(current) && !seen.has(current)) {
|
|
362
|
+
seen.add(current);
|
|
363
|
+
chain.unshift(current);
|
|
364
|
+
current = parentOf.get(current) ?? null;
|
|
365
|
+
}
|
|
366
|
+
return chain.map((sessionId) => ({ sessionId, events: logs.get(sessionId) ?? [] }));
|
|
367
|
+
};
|
|
368
|
+
// A tick reports the admission line by default, where `inspectSession` and
|
|
369
|
+
// `inspectFactory` stay silent unless asked. The asymmetry is deliberate: a
|
|
370
|
+
// caller who reaches for one function is answering their own question and
|
|
371
|
+
// may not want two policy files read, whereas the whole point of a tick is
|
|
372
|
+
// to be the surface nobody is watching — a status file that omitted the one
|
|
373
|
+
// column saying whose queue a finding is in would be omitting it precisely
|
|
374
|
+
// when it matters. Reading policy here also fails loudly at the top of the
|
|
375
|
+
// tick rather than silently downgrading a finding to unadmitted.
|
|
376
|
+
const admission = opts.admission ?? {
|
|
377
|
+
autonomy: (opts.schedulerPolicy ?? loadSchedulerPolicy()).autonomy,
|
|
378
|
+
securityKeywords: loadCrosscheckPolicy().planQuorum.securityKeywords,
|
|
379
|
+
};
|
|
380
|
+
const inspectOpts = {
|
|
381
|
+
now,
|
|
382
|
+
admission,
|
|
383
|
+
...(opts.budgetPolicy === undefined ? {} : { budgetPolicy: opts.budgetPolicy }),
|
|
384
|
+
...(opts.schedulerPolicy === undefined ? {} : { schedulerPolicy: opts.schedulerPolicy }),
|
|
385
|
+
...(opts.staleHours === undefined ? {} : { staleHours: opts.staleHours }),
|
|
386
|
+
...(opts.projectDirs === undefined ? {} : { projectDirs: opts.projectDirs }),
|
|
387
|
+
...(opts.readOutdated === undefined ? {} : { readOutdated: opts.readOutdated }),
|
|
388
|
+
...(opts.readProjects === undefined ? {} : { readProjects: opts.readProjects }),
|
|
389
|
+
};
|
|
390
|
+
const projectDb = opts.projectDb ?? true;
|
|
391
|
+
const dbPath = opts.dbPath ?? STATE_DB_PATH;
|
|
392
|
+
const dbOpts = { stateDir, ...opts.dbOpts };
|
|
393
|
+
let projected = 0;
|
|
394
|
+
// Carries no session when there was none to blame. Clearable either way: the
|
|
395
|
+
// next tick that can open the file drops it.
|
|
396
|
+
const projectionFailed = (sessionId, err) => ({
|
|
397
|
+
kind: 'projection-failed',
|
|
398
|
+
severity: 'attention',
|
|
399
|
+
sessionId,
|
|
400
|
+
subject: dbPath,
|
|
401
|
+
detail: `The read-model refresh failed: ${errorMessage(err)}. The event log is unaffected — ` +
|
|
402
|
+
'it is the UI and `smith status` that are now stale.',
|
|
403
|
+
});
|
|
404
|
+
for (const leaf of leaves) {
|
|
405
|
+
findings.push(...inspectSession(leaf, mergeSessionLogs(lineageOf(leaf)), inspectOpts));
|
|
406
|
+
if (!projectDb)
|
|
407
|
+
continue;
|
|
408
|
+
try {
|
|
409
|
+
await apply(dbPath, leaf, dbOpts);
|
|
410
|
+
projected += 1;
|
|
411
|
+
}
|
|
412
|
+
catch (err) {
|
|
413
|
+
findings.push(projectionFailed(leaf, err));
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
// The read-model is not made of sessions alone. `milestones` is a full
|
|
417
|
+
// replacement of roadmap.md on every projector call -- projector.ts calls it
|
|
418
|
+
// "not session-scoped" in as many words -- and `apply()` inside the loop
|
|
419
|
+
// above was the only thing a running daemon ever called. So a tick that
|
|
420
|
+
// folded no session refreshed nothing, and on a fresh clone that meant no
|
|
421
|
+
// database file at all: the operator writes a roadmap, starts the watcher,
|
|
422
|
+
// opens the UI, and the Roadmap view and the project switcher are both
|
|
423
|
+
// empty until somebody happens to run `smith db rebuild` by hand. Rebuilding
|
|
424
|
+
// is the honest answer to "no session to fold" and not merely the
|
|
425
|
+
// convenient one: with an empty log it clears nothing and projects the
|
|
426
|
+
// roadmap, which is exactly what that hand-run command does. It stays cheap
|
|
427
|
+
// because it is unreachable the moment one session exists.
|
|
428
|
+
if (projectDb && leaves.length === 0) {
|
|
429
|
+
try {
|
|
430
|
+
await rebuild(dbPath, 'all', dbOpts);
|
|
431
|
+
}
|
|
432
|
+
catch (err) {
|
|
433
|
+
findings.push(projectionFailed(null, err));
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
// Always, including on a clone whose event log is empty. This used to be
|
|
437
|
+
// gated on there being a session to fold, which suppressed the right half
|
|
438
|
+
// for the wrong reason: growth-review is the only finding here that is a
|
|
439
|
+
// claim about elapsed history, and `inspectFactory` now gates that branch
|
|
440
|
+
// itself. Maintenance reads lockfiles and `unwatched-project` reads the
|
|
441
|
+
// register; a dependency falls behind on the registry's clock and a repo
|
|
442
|
+
// goes unwatched the moment it is scaffolded, so a factory that has built
|
|
443
|
+
// nothing still has repos to tend.
|
|
444
|
+
const all = [...logs.entries()].map(([sessionId, events]) => ({ sessionId, events }));
|
|
445
|
+
findings.push(...inspectFactory(mergeSessionLogs(all), inspectOpts));
|
|
446
|
+
const aged = ageFindings(opts.memory ?? {}, findings, now);
|
|
447
|
+
return {
|
|
448
|
+
at: now.toISOString(),
|
|
449
|
+
sessions: leaves,
|
|
450
|
+
findings: aged,
|
|
451
|
+
attention: aged.filter((f) => f.severity === 'attention').length,
|
|
452
|
+
newAttention: aged.filter((f) => f.severity === 'attention' && f.isNew).length,
|
|
453
|
+
autoAdmitted: aged.filter((f) => f.admission?.decision === 'auto').length,
|
|
454
|
+
operatorHeld: aged.filter((f) => f.admission?.decision === 'operator').length,
|
|
455
|
+
projected,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
export function lockPath(dir) {
|
|
459
|
+
return path.join(dir, LOCK_FILE);
|
|
460
|
+
}
|
|
461
|
+
export function statusPath(dir) {
|
|
462
|
+
return path.join(dir, STATUS_FILE);
|
|
463
|
+
}
|
|
464
|
+
function readJsonFile(filePath) {
|
|
465
|
+
if (!existsSync(filePath))
|
|
466
|
+
return null;
|
|
467
|
+
try {
|
|
468
|
+
return JSON.parse(readFileSync(filePath, 'utf8'));
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
return null;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
export function readLock(dir) {
|
|
475
|
+
const lock = readJsonFile(lockPath(dir));
|
|
476
|
+
if (lock === null || typeof lock.pid !== 'number')
|
|
477
|
+
return null;
|
|
478
|
+
return lock;
|
|
479
|
+
}
|
|
480
|
+
/** `kill(pid, 0)` — EPERM means a process exists that this user may not signal. */
|
|
481
|
+
export function processIsAlive(pid) {
|
|
482
|
+
try {
|
|
483
|
+
process.kill(pid, 0);
|
|
484
|
+
return true;
|
|
485
|
+
}
|
|
486
|
+
catch (err) {
|
|
487
|
+
return err.code === 'EPERM';
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Claim `dir` for `lock.pid`, returning the lock this one displaced (null when
|
|
492
|
+
* there was nothing to displace, or nothing readable).
|
|
493
|
+
*
|
|
494
|
+
* Two daemons ticking the same state dir would double every projection write
|
|
495
|
+
* and every finding, so a live incumbent is refused. A DEAD incumbent is not:
|
|
496
|
+
* a crashed daemon leaves its pid file behind, and refusing to start until a
|
|
497
|
+
* human runs `rm` would make the first crash permanent. Likewise an
|
|
498
|
+
* unparseable file — half a JSON document is what a crash mid-write looks
|
|
499
|
+
* like, and it is evidence of a dead writer, not a live one.
|
|
500
|
+
*/
|
|
501
|
+
export function acquireLock(dir, lock, opts = {}) {
|
|
502
|
+
const isAlive = opts.isAlive ?? processIsAlive;
|
|
503
|
+
mkdirSync(dir, { recursive: true });
|
|
504
|
+
const file = lockPath(dir);
|
|
505
|
+
const body = `${JSON.stringify(lock, null, 2)}\n`;
|
|
506
|
+
try {
|
|
507
|
+
writeFileSync(file, body, { encoding: 'utf8', flag: 'wx' });
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
catch (err) {
|
|
511
|
+
if (err.code !== 'EEXIST')
|
|
512
|
+
throw err;
|
|
513
|
+
}
|
|
514
|
+
const incumbent = readLock(dir);
|
|
515
|
+
if (incumbent !== null && isAlive(incumbent.pid)) {
|
|
516
|
+
throw new DaemonError('daemon.already-running', `A daemon (pid ${incumbent.pid}, started ${incumbent.startedAt}) already holds ${file}. ` +
|
|
517
|
+
'Stop it with `smith daemon stop` before starting another.', { pid: incumbent.pid, lock_path: file });
|
|
518
|
+
}
|
|
519
|
+
writeFileSync(file, body, 'utf8');
|
|
520
|
+
return incumbent;
|
|
521
|
+
}
|
|
522
|
+
/** True when this call removed the lock; false when it belongs to someone else. */
|
|
523
|
+
export function releaseLock(dir, pid) {
|
|
524
|
+
const lock = readLock(dir);
|
|
525
|
+
if (lock === null || lock.pid !== pid)
|
|
526
|
+
return false;
|
|
527
|
+
rmSync(lockPath(dir), { force: true });
|
|
528
|
+
return true;
|
|
529
|
+
}
|
|
530
|
+
// ---------------------------------------------------------------------------
|
|
531
|
+
// The status file
|
|
532
|
+
// ---------------------------------------------------------------------------
|
|
533
|
+
/** tmp-then-rename, so a reader polling this file never sees half a document. */
|
|
534
|
+
export function writeStatus(dir, report) {
|
|
535
|
+
mkdirSync(dir, { recursive: true });
|
|
536
|
+
const target = statusPath(dir);
|
|
537
|
+
const tmp = `${target}.tmp`;
|
|
538
|
+
writeFileSync(tmp, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
|
539
|
+
renameSync(tmp, target);
|
|
540
|
+
}
|
|
541
|
+
export function readStatus(dir) {
|
|
542
|
+
return readJsonFile(statusPath(dir));
|
|
543
|
+
}
|
|
544
|
+
// ---------------------------------------------------------------------------
|
|
545
|
+
// The finding memory
|
|
546
|
+
// ---------------------------------------------------------------------------
|
|
547
|
+
export function memoryPath(dir) {
|
|
548
|
+
return path.join(dir, MEMORY_FILE);
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* The only state this daemon carries across ticks, and deliberately the least
|
|
552
|
+
* it could carry: identity -> when it was first seen.
|
|
553
|
+
*
|
|
554
|
+
* Missing or corrupt reads as empty rather than throwing, for the same reason
|
|
555
|
+
* `unreadable-log` is a finding and not a crash. A watchdog that dies over its
|
|
556
|
+
* own scratch file is silent exactly when something is wrong, and the cost of
|
|
557
|
+
* losing this one is a single tick that calls every standing finding new.
|
|
558
|
+
*/
|
|
559
|
+
export function readFindingMemory(dir) {
|
|
560
|
+
return readJsonFile(memoryPath(dir)) ?? {};
|
|
561
|
+
}
|
|
562
|
+
/** tmp-then-rename, like the status file: a half-written memory reads as none. */
|
|
563
|
+
export function writeFindingMemory(dir, memory) {
|
|
564
|
+
mkdirSync(dir, { recursive: true });
|
|
565
|
+
const target = memoryPath(dir);
|
|
566
|
+
const tmp = `${target}.tmp`;
|
|
567
|
+
writeFileSync(tmp, `${JSON.stringify(memory, null, 2)}\n`, 'utf8');
|
|
568
|
+
renameSync(tmp, target);
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* How long a daemon may go quiet before the silence is itself the finding.
|
|
572
|
+
*
|
|
573
|
+
* Three intervals is the miss-two-heartbeats rule: one late tick is a slow fold
|
|
574
|
+
* of a long log, two in a row is a fault. The floor is there because a tick's
|
|
575
|
+
* cost is not proportional to the interval -- folding the event log takes what
|
|
576
|
+
* it takes -- so `--interval 1` would otherwise report a daemon as wedged for
|
|
577
|
+
* doing exactly the work it was told to do too often.
|
|
578
|
+
*/
|
|
579
|
+
const STALE_INTERVALS = 3;
|
|
580
|
+
const STALE_FLOOR_SECONDS = 60;
|
|
581
|
+
function staleToleranceSeconds(lock) {
|
|
582
|
+
// D-21: a report that only states a fact must not crash over that fact, and a
|
|
583
|
+
// hand-edited pid file is a bad lock rather than a reason to have no status.
|
|
584
|
+
const interval = typeof lock.intervalSeconds === 'number' && lock.intervalSeconds > 0
|
|
585
|
+
? lock.intervalSeconds
|
|
586
|
+
: DEFAULT_INTERVAL_SECONDS;
|
|
587
|
+
return Math.max(interval * STALE_INTERVALS, STALE_FLOOR_SECONDS);
|
|
588
|
+
}
|
|
589
|
+
/** Seconds since `iso`, or null when there is no readable timestamp to date. */
|
|
590
|
+
function ageSeconds(now, iso) {
|
|
591
|
+
if (iso === undefined)
|
|
592
|
+
return null;
|
|
593
|
+
const then = Date.parse(iso);
|
|
594
|
+
if (Number.isNaN(then))
|
|
595
|
+
return null;
|
|
596
|
+
return Math.round((now.getTime() - then) / 1000);
|
|
597
|
+
}
|
|
598
|
+
export function daemonStatus(dir, opts = {}) {
|
|
599
|
+
const isAlive = opts.isAlive ?? processIsAlive;
|
|
600
|
+
const now = opts.now ?? new Date();
|
|
601
|
+
const lock = readLock(dir);
|
|
602
|
+
const lastTick = readStatus(dir);
|
|
603
|
+
const running = lock !== null && isAlive(lock.pid);
|
|
604
|
+
const reportAge = ageSeconds(now, lastTick?.at);
|
|
605
|
+
// The freshest thing this daemon is known to have done. `min` rather than the
|
|
606
|
+
// tick alone: a daemon that restarted after a long quiet spell has done
|
|
607
|
+
// something more recent than its predecessor's last report, and dating it
|
|
608
|
+
// from that report would alarm on a watcher that is two seconds old.
|
|
609
|
+
const lockAge = lock === null ? null : ageSeconds(now, lock.startedAt);
|
|
610
|
+
const ages = [reportAge, lockAge].filter((a) => a !== null);
|
|
611
|
+
const quietFor = ages.length === 0 ? null : Math.min(...ages);
|
|
612
|
+
return {
|
|
613
|
+
running,
|
|
614
|
+
stale: running && lock !== null && quietFor !== null && quietFor > staleToleranceSeconds(lock),
|
|
615
|
+
dir,
|
|
616
|
+
lock,
|
|
617
|
+
lastTick,
|
|
618
|
+
reportAgeSeconds: reportAge,
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Deliberately NOT unref'd. An unref'd timer lets node decide the event loop
|
|
623
|
+
* is empty and exit mid-sleep — skipping the `finally` below, and leaving a
|
|
624
|
+
* lock behind naming a pid that is already gone.
|
|
625
|
+
*/
|
|
626
|
+
function defaultSleep(ms) {
|
|
627
|
+
return new Promise((resolve) => {
|
|
628
|
+
setTimeout(resolve, ms);
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Hold the lock, tick, publish, repeat.
|
|
633
|
+
*
|
|
634
|
+
* The `finally` is the point of the whole function: a loop that exits still
|
|
635
|
+
* holding its lock — because a tick threw, because the operator hit ^C — is a
|
|
636
|
+
* loop nothing can restart without a human deleting a file.
|
|
637
|
+
*/
|
|
638
|
+
export async function runDaemon(opts) {
|
|
639
|
+
const pid = opts.pid ?? process.pid;
|
|
640
|
+
const intervalSeconds = opts.intervalSeconds ?? DEFAULT_INTERVAL_SECONDS;
|
|
641
|
+
const startedAt = (opts.now ?? new Date()).toISOString();
|
|
642
|
+
const tick = opts.tick ?? runTick;
|
|
643
|
+
const sleep = opts.sleep ?? defaultSleep;
|
|
644
|
+
const shouldContinue = opts.shouldContinue ?? (() => true);
|
|
645
|
+
acquireLock(opts.dir, { pid, startedAt, intervalSeconds }, opts.isAlive === undefined ? {} : { isAlive: opts.isAlive });
|
|
646
|
+
const tickOptions = {
|
|
647
|
+
...(opts.now === undefined ? {} : { now: opts.now }),
|
|
648
|
+
...(opts.budgetPolicy === undefined ? {} : { budgetPolicy: opts.budgetPolicy }),
|
|
649
|
+
...(opts.schedulerPolicy === undefined ? {} : { schedulerPolicy: opts.schedulerPolicy }),
|
|
650
|
+
...(opts.staleHours === undefined ? {} : { staleHours: opts.staleHours }),
|
|
651
|
+
...(opts.projectDirs === undefined ? {} : { projectDirs: opts.projectDirs }),
|
|
652
|
+
...(opts.readProjects === undefined ? {} : { readProjects: opts.readProjects }),
|
|
653
|
+
...(opts.stateDir === undefined ? {} : { stateDir: opts.stateDir }),
|
|
654
|
+
...(opts.projectDb === undefined ? {} : { projectDb: opts.projectDb }),
|
|
655
|
+
...(opts.dbPath === undefined ? {} : { dbPath: opts.dbPath }),
|
|
656
|
+
...(opts.dbOpts === undefined ? {} : { dbOpts: opts.dbOpts }),
|
|
657
|
+
};
|
|
658
|
+
// Read from disk rather than held in a variable, so a daemon restarted by
|
|
659
|
+
// cron or by the operator picks up where the last process stopped. A tick
|
|
660
|
+
// knows nothing about the interval it runs on; this is what joins two.
|
|
661
|
+
const tickWithMemory = async () => {
|
|
662
|
+
const report = await tick({ ...tickOptions, memory: readFindingMemory(opts.dir) });
|
|
663
|
+
writeStatus(opts.dir, report);
|
|
664
|
+
writeFindingMemory(opts.dir, memoryOf(report.findings));
|
|
665
|
+
return report;
|
|
666
|
+
};
|
|
667
|
+
const reports = [];
|
|
668
|
+
try {
|
|
669
|
+
if (opts.once === true) {
|
|
670
|
+
reports.push(await tickWithMemory());
|
|
671
|
+
return reports;
|
|
672
|
+
}
|
|
673
|
+
while (shouldContinue()) {
|
|
674
|
+
reports.push(await tickWithMemory());
|
|
675
|
+
await sleep(intervalSeconds * 1000);
|
|
676
|
+
}
|
|
677
|
+
return reports;
|
|
678
|
+
}
|
|
679
|
+
finally {
|
|
680
|
+
releaseLock(opts.dir, pid);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* SIGTERM the process the lock names, then clear the file.
|
|
685
|
+
*
|
|
686
|
+
* `stopped` answers "did this call signal a running daemon", so a stale lock
|
|
687
|
+
* left by a crash reports `stopped: false` with the pid it cleared — the
|
|
688
|
+
* operator learns the daemon was already gone rather than being told it was
|
|
689
|
+
* just stopped.
|
|
690
|
+
*/
|
|
691
|
+
export function stopDaemon(dir, opts = {}) {
|
|
692
|
+
const isAlive = opts.isAlive ?? processIsAlive;
|
|
693
|
+
const kill = opts.kill ??
|
|
694
|
+
((pid) => {
|
|
695
|
+
process.kill(pid, 'SIGTERM');
|
|
696
|
+
});
|
|
697
|
+
const lock = readLock(dir);
|
|
698
|
+
if (lock === null)
|
|
699
|
+
return { stopped: false, pid: null };
|
|
700
|
+
const alive = isAlive(lock.pid);
|
|
701
|
+
if (alive)
|
|
702
|
+
kill(lock.pid);
|
|
703
|
+
rmSync(lockPath(dir), { force: true });
|
|
704
|
+
return { stopped: alive, pid: lock.pid };
|
|
705
|
+
}
|
|
706
|
+
/** Where `smith daemon` keeps its lock and its last tick, when nobody says otherwise. */
|
|
707
|
+
export const DEFAULT_DAEMON_DIR = STATE_DAEMON_DIR;
|