@aiwg/cli 2026.10.5 → 2026.10.6
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/agentic/code/addons/composition-engine/lib/runtime.mjs +1401 -0
- package/agentic/code/addons/composition-engine/lib/validator.mjs +513 -0
- package/agentic/code/addons/composition-engine/schemas/flow-graph.schema.json +372 -0
- package/agentic/code/providers/model-capabilities.v1.json +74 -50
- package/bin/aiwg.mjs +3 -1
- package/dist/src/artifacts/browser-export.js +1 -0
- package/dist/src/artifacts/citation-parser.js +8 -1
- package/dist/src/artifacts/cli.js +57 -6
- package/dist/src/artifacts/fortemi-core-query-adapter.js +2 -0
- package/dist/src/artifacts/fortemi-core-sync.js +26 -8
- package/dist/src/artifacts/graph-query.js +18 -8
- package/dist/src/artifacts/index-adapters.js +339 -0
- package/dist/src/artifacts/index-builder.js +143 -14
- package/dist/src/artifacts/index-files.js +15 -4
- package/dist/src/artifacts/index-reader.js +44 -0
- package/dist/src/artifacts/index-refresh.js +114 -55
- package/dist/src/artifacts/index-status.js +20 -5
- package/dist/src/artifacts/memory-maintenance.js +503 -0
- package/dist/src/artifacts/prebuilt-relations.js +3 -1
- package/dist/src/artifacts/query-engine.js +263 -108
- package/dist/src/artifacts/relations-builder.js +2 -2
- package/dist/src/artifacts/source-graph.js +2 -2
- package/dist/src/artifacts/types.js +9 -1
- package/dist/src/cli/handlers/decision.js +19 -1
- package/dist/src/cli/handlers/guided-setup.js +470 -0
- package/dist/src/cli/handlers/help.js +2 -2
- package/dist/src/cli/handlers/housekeeper.js +1110 -0
- package/dist/src/cli/handlers/index.js +4 -2
- package/dist/src/cli/handlers/init.js +41 -6
- package/dist/src/cli/handlers/local-executor.js +5 -3
- package/dist/src/cli/handlers/mc.js +509 -53
- package/dist/src/cli/handlers/models.js +32 -2
- package/dist/src/cli/handlers/ralph-launcher.js +76 -4
- package/dist/src/cli/handlers/rules.js +1 -1
- package/dist/src/cli/handlers/script-runner.js +73 -2
- package/dist/src/cli/handlers/serve.js +5 -3
- package/dist/src/cli/handlers/sessions.js +1 -0
- package/dist/src/cli/handlers/setup.js +428 -1
- package/dist/src/cli/handlers/skill-lint.js +1 -1
- package/dist/src/cli/handlers/subcommands.js +118 -8
- package/dist/src/cli/handlers/use.js +279 -93
- package/dist/src/cli/handlers/workspace.js +3 -1
- package/dist/src/cli/prompt-utils.js +8 -0
- package/dist/src/cli/router.js +16 -1
- package/dist/src/config/aiwg-config.js +42 -0
- package/dist/src/config/cli.js +7 -3
- package/dist/src/config/decision-config.js +14 -1
- package/dist/src/config/native-sandbox-warnings.js +25 -0
- package/dist/src/contributors/validation.js +6 -5
- package/dist/src/decision/admission.js +7 -3
- package/dist/src/decision/benchmark.js +469 -0
- package/dist/src/decision/ledger.js +22 -3
- package/dist/src/decision/pricing.js +147 -0
- package/dist/src/decision/qualification/gate-evidence.js +18 -15
- package/dist/src/decision/qualification/load-staged.js +539 -0
- package/dist/src/decision/qualification/load.js +109 -33
- package/dist/src/decision/qualification/runner.js +5 -2
- package/dist/src/decision/review/service.js +6 -4
- package/dist/src/decision/review/validate.js +23 -2
- package/dist/src/decision/savings.js +8 -3
- package/dist/src/discovery/index.js +492 -0
- package/dist/src/discovery/interview.js +233 -0
- package/dist/src/discovery/plan-apply.js +571 -0
- package/dist/src/discovery/plan-store.js +171 -0
- package/dist/src/discovery/plan-types.js +2 -0
- package/dist/src/discovery/plan-validation.js +139 -0
- package/dist/src/discovery/types.js +2 -0
- package/dist/src/extensions/commands/definitions.js +49 -9
- package/dist/src/extensions/manifest.js +71 -44
- package/dist/src/extensions/project-local-discovery.js +1 -1
- package/dist/src/extensions/project-quickref.js +18 -17
- package/dist/src/extensions/validation.js +32 -29
- package/dist/src/housekeeper/daemon-config.js +148 -0
- package/dist/src/housekeeper/ipc.js +84 -0
- package/dist/src/housekeeper/lifecycle.js +148 -0
- package/dist/src/housekeeper/platform.js +35 -0
- package/dist/src/housekeeper/status.js +70 -0
- package/dist/src/housekeeper/units.js +114 -0
- package/dist/src/jobs/flow.js +22 -21
- package/dist/src/lint/rule-activation.js +20 -5
- package/dist/src/lint/runner.js +1 -1
- package/dist/src/mcp/cli.mjs +5 -6
- package/dist/src/mcp/config-file.mjs +6 -2
- package/dist/src/mcp/config-layers.mjs +23 -7
- package/dist/src/mcp/profiles.js +18 -31
- package/dist/src/mcp/profiles.mjs +18 -32
- package/dist/src/mcp/registry.js +7 -27
- package/dist/src/mcp/registry.mjs +7 -28
- package/dist/src/mcp/server.mjs +34 -1
- package/dist/src/mcp/tools/agentic-sandbox.mjs +8 -7
- package/dist/src/mcp/tools/command-run.mjs +3 -2
- package/dist/src/mcp/tools/decision.mjs +49 -48
- package/dist/src/mcp/tools/discovery.mjs +46 -21
- package/dist/src/mcp/tools/orchestration.mjs +22 -22
- package/dist/src/mcp/tools/subsystems.mjs +9 -8
- package/dist/src/mcp/tools/worker-invoke.mjs +17 -4
- package/dist/src/mcp/tools/writer-profiles.mjs +1 -1
- package/dist/src/memory/audit.js +719 -0
- package/dist/src/memory/canonical-context.js +392 -13
- package/dist/src/memory/canonical-maintenance.js +95 -0
- package/dist/src/memory/cli-result.js +22 -0
- package/dist/src/memory/cli.js +176 -0
- package/dist/src/memory/context-pack.js +57 -6
- package/dist/src/memory/cost.js +512 -0
- package/dist/src/memory/firewall-classification.d.mts +7 -0
- package/dist/src/memory/firewall-classification.mjs +32 -0
- package/dist/src/memory/index.js +8 -0
- package/dist/src/memory/line-maintenance.js +68 -0
- package/dist/src/memory/maintenance-cli.js +286 -0
- package/dist/src/memory/maintenance-contract.js +136 -0
- package/dist/src/memory/maintenance-index.js +18 -0
- package/dist/src/memory/maintenance-owner.js +2 -0
- package/dist/src/memory/maintenance-record.js +138 -0
- package/dist/src/memory/maintenance.js +254 -0
- package/dist/src/memory/provider-memory-adapter.js +154 -0
- package/dist/src/memory/recommend-cli.js +83 -0
- package/dist/src/memory/recommend.js +269 -0
- package/dist/src/memory/scheduled.js +81 -0
- package/dist/src/memory/taxonomy.js +44 -0
- package/dist/src/mission-protocol/codecs.js +19 -9
- package/dist/src/models/config-loader.js +11 -1
- package/dist/src/models/model-capabilities.v1.json +74 -50
- package/dist/src/models/provider-policy.js +125 -62
- package/dist/src/models/resolver.js +45 -8
- package/dist/src/models/worker-installation.d.mts +7 -0
- package/dist/src/models/worker-installation.mjs +169 -37
- package/dist/src/models/wrapper-route.js +1 -1
- package/dist/src/output-modes/registry.js +8 -7
- package/dist/src/plugin/metadata-validator.js +1 -1
- package/dist/src/providers/deploy-version.d.mts +1 -0
- package/dist/src/providers/deploy-version.mjs +17 -0
- package/dist/src/providers/provider-definitions.js +19 -16
- package/dist/src/providers/skill-ownership.d.mts +11 -2
- package/dist/src/providers/skill-ownership.mjs +35 -17
- package/dist/src/rules/activation.d.mts +11 -2
- package/dist/src/rules/activation.mjs +82 -6
- package/dist/src/rules/inventory.js +1 -1
- package/dist/src/rules/rule-frontmatter.schema.json +2 -0
- package/dist/src/schema/zod-validation.d.mts +6 -0
- package/dist/src/schema/zod-validation.mjs +50 -0
- package/dist/src/serve/executor-registry.js +297 -20
- package/dist/src/serve/executor-token.d.mts +1 -0
- package/dist/src/serve/executor-token.mjs +8 -0
- package/dist/src/serve/shared-host-scheduler.js +1102 -31
- package/dist/src/serve/worker-invocation.js +114 -15
- package/dist/src/sessions/adapters/claude.js +19 -18
- package/dist/src/sessions/adapters/codex.js +10 -9
- package/dist/src/sessions/adapters/copilot.js +14 -13
- package/dist/src/sessions/adapters/cursor.js +19 -18
- package/dist/src/sessions/adapters/deepseek-harness.js +9 -8
- package/dist/src/sessions/adapters/factory.js +11 -10
- package/dist/src/sessions/adapters/generic.js +32 -23
- package/dist/src/sessions/adapters/grokbot.js +35 -35
- package/dist/src/sessions/adapters/hermes.js +20 -19
- package/dist/src/sessions/adapters/muse.js +50 -49
- package/dist/src/sessions/adapters/omp.js +5 -4
- package/dist/src/sessions/adapters/openclaw.js +30 -29
- package/dist/src/sessions/adapters/opencode.js +26 -26
- package/dist/src/sessions/adapters/openhuman.js +24 -23
- package/dist/src/sessions/adapters/pi.js +5 -4
- package/dist/src/sessions/adapters/windsurf.js +8 -8
- package/dist/src/sessions/analytics.js +10 -9
- package/dist/src/sessions/candidates.js +7 -6
- package/dist/src/sessions/contracts.js +63 -53
- package/dist/src/sessions/output-registration.js +11 -10
- package/dist/src/sessions/workspace-discovery.js +3 -4
- package/dist/src/smiths/context-pipeline/aiwg-md.js +18 -1
- package/dist/src/smiths/context-pipeline/claude-hook.js +42 -6
- package/dist/src/smiths/context-pipeline/parallelism-section.js +72 -5
- package/dist/src/smiths/context-pipeline/workspace-context.js +106 -10
- package/dist/src/update/checker.d.mts +4 -0
- package/dist/src/update/checker.mjs +14 -0
- package/dist/src/writing/exemplar-selection.js +6 -6
- package/dist/src/writing/voice-evaluation.js +24 -23
- package/dist/src/writing/writer-migration.js +14 -13
- package/dist/src/writing/writer-profile.js +19 -18
- package/dist/src/writing/writing-brief.js +18 -17
- package/dist/src/writing/writing-receipt.js +47 -46
- package/package.json +5 -2
- package/schemas/decision/DecisionReview.schema.json +78 -1
- package/schemas/mission-v1.schema.json +3 -0
- package/tools/agents/deploy-agents.mjs +67 -39
- package/tools/agents/providers/antigravity.mjs +6 -1
- package/tools/agents/providers/base.mjs +19 -13
- package/tools/agents/providers/claude.mjs +1 -1
- package/tools/agents/providers/codex.mjs +50 -56
- package/tools/agents/providers/copilot.mjs +8 -2
- package/tools/agents/providers/cursor.mjs +1 -1
- package/tools/agents/providers/deepseek-harness.mjs +1 -1
- package/tools/agents/providers/factory.mjs +7 -1
- package/tools/agents/providers/grok-build.mjs +18 -2
- package/tools/agents/providers/grokbot.mjs +1 -1
- package/tools/agents/providers/hermes.mjs +7 -13
- package/tools/agents/providers/muse-hooks.mjs +21 -14
- package/tools/agents/providers/muse.mjs +7 -2
- package/tools/agents/providers/omp.mjs +28 -4
- package/tools/agents/providers/opencode.mjs +14 -5
- package/tools/agents/providers/openhuman.mjs +1 -1
- package/tools/agents/providers/pi.mjs +1 -1
- package/tools/agents/providers/provider-prepare-coordinator.mjs +29 -9
- package/tools/agents/providers/rule-delivery.mjs +263 -71
- package/tools/agents/providers/windsurf.mjs +7 -0
- package/tools/cli/workspace-status.mjs +5 -0
- package/tools/commands/deploy-prompts-codex.mjs +11 -8
- package/tools/ralph-external/lib/codex-adapter.mjs +6 -2
- package/tools/ralph-external/lib/muse-adapter.mjs +12 -2
- package/tools/ralph-external/lib/worker-health.mjs +1 -1
- package/tools/ralph-external/lib/worker-isolation.mjs +65 -9
- package/tools/ralph-external/process-monitor.mjs +7 -4
- package/tools/ralph-external/session-launcher.mjs +280 -32
- package/tools/skills/deploy-skills-codex.mjs +11 -7
- package/vscode-extension/schemas/aiwg.config.v1.json +11 -1
|
@@ -0,0 +1,1110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Housekeeper command handler (epic #3353 slice B5).
|
|
3
|
+
*
|
|
4
|
+
* `aiwg housekeeper start|stop|status|run-once`,
|
|
5
|
+
* `aiwg housekeeper job list|register|disable`,
|
|
6
|
+
* `aiwg housekeeper watch list|register|disable`,
|
|
7
|
+
* `aiwg housekeeper install|uninstall`.
|
|
8
|
+
*
|
|
9
|
+
* Conventions followed: strict flag parsing via `src/cli/flag-parsing.ts`
|
|
10
|
+
* (a present-but-valueless flag is exit 2 before any spawn or write),
|
|
11
|
+
* `isInteractive` for every prompt decision, `handlerResultFromError`
|
|
12
|
+
* in the top-level catch, and ChildProcess signalling via `signalChild`.
|
|
13
|
+
* The daemon itself is reused from `tools/daemon/` (decision D1) through
|
|
14
|
+
* the script runner and spawned processes — `src/` never imports `tools/`.
|
|
15
|
+
*
|
|
16
|
+
* @tests @test/unit/cli/handlers/housekeeper.test.ts
|
|
17
|
+
*/
|
|
18
|
+
import { spawn } from 'node:child_process';
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { signalChild } from '../../process/signal-child.mjs';
|
|
22
|
+
import { EXIT_CODES, handlerResultFromError } from '../errors.js';
|
|
23
|
+
import { MissingFlagValueError, requireFlagValue } from '../flag-parsing.js';
|
|
24
|
+
import { isInteractive } from '../env.js';
|
|
25
|
+
import { askString, askYesNo, createPromptInterface, listSelect } from '../prompt-utils.js';
|
|
26
|
+
import { getFrameworkRoot } from '../../channel/manager.mjs';
|
|
27
|
+
import { createScriptRunner } from './script-runner.js';
|
|
28
|
+
import { HOUSEKEEPER_PROFILE, MAINTENANCE_KINDS, validateMemoryJobArgs, getDaemonProfile, getScheduleJobs, getWatchPaths, loadDaemonConfig, saveDaemonConfig, seedHousekeeperConfig, setDaemonProfile, setScheduleJobs, setWatchPaths, } from '../../housekeeper/daemon-config.js';
|
|
29
|
+
import { daemonPaths, describeRunning, pollUntil, removeLifecycleFiles, stopDaemon, } from '../../housekeeper/lifecycle.js';
|
|
30
|
+
import { requestDaemon } from '../../housekeeper/ipc.js';
|
|
31
|
+
import { buildHousekeeperStatus, normalizeJobState, toFreshnessEntry, } from '../../housekeeper/status.js';
|
|
32
|
+
import { installBackends, normalizePlatform, runSupported, runUnsupportedMessage, } from '../../housekeeper/platform.js';
|
|
33
|
+
import { HOUSEKEEPER_LAUNCHD_LABEL, HOUSEKEEPER_SERVICE_NAME, HOUSEKEEPER_TIMER_NAME, defaultAiwgBin, launchdAgentDir, renderLaunchdPlist, renderSystemdService, renderSystemdTimer, systemdUserDir, } from '../../housekeeper/units.js';
|
|
34
|
+
const VALUE_FLAGS = [
|
|
35
|
+
'--job', '--id', '--kind', '--schedule', '--cron', '--timezone',
|
|
36
|
+
'--timeout-ms', '--retries', '--path', '--events', '--extensions',
|
|
37
|
+
'--fire-time', '--calendar', '--state-dir',
|
|
38
|
+
'--scope', '--provider', '--evidence', '--usage', '--plan', '--confirmed-digest',
|
|
39
|
+
];
|
|
40
|
+
const SWITCH_FLAGS = new Set([
|
|
41
|
+
'--foreground', '--force', '--json', '--dry-run', '--non-interactive',
|
|
42
|
+
'--systemd-user', '--launchd', '--with-timer', '--yes', '-y',
|
|
43
|
+
]);
|
|
44
|
+
function flagValue(args, ...names) {
|
|
45
|
+
return requireFlagValue(args, {}, ...names);
|
|
46
|
+
}
|
|
47
|
+
function hasSwitch(args, ...names) {
|
|
48
|
+
return names.some((name) => args.includes(name));
|
|
49
|
+
}
|
|
50
|
+
function usageError(message, hint) {
|
|
51
|
+
return {
|
|
52
|
+
exitCode: EXIT_CODES.USAGE,
|
|
53
|
+
message: hint ? `${message} — hint: ${hint}` : message,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function checkUnknownFlags(args, command) {
|
|
57
|
+
for (const token of args) {
|
|
58
|
+
if (token === '--')
|
|
59
|
+
break;
|
|
60
|
+
if (!token.startsWith('--'))
|
|
61
|
+
continue;
|
|
62
|
+
const name = token.includes('=') ? token.slice(0, token.indexOf('=')) : token;
|
|
63
|
+
if (VALUE_FLAGS.includes(name))
|
|
64
|
+
continue;
|
|
65
|
+
if (SWITCH_FLAGS.has(name))
|
|
66
|
+
continue;
|
|
67
|
+
return usageError(`aiwg housekeeper ${command}: unknown flag '${name}'.`, 'Run `aiwg housekeeper --help` for usage.');
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
export function housekeeperHelpText() {
|
|
72
|
+
return [
|
|
73
|
+
'AIWG housekeeper — deterministic maintenance daemon (epic #3353).',
|
|
74
|
+
'',
|
|
75
|
+
'Usage:',
|
|
76
|
+
' aiwg housekeeper start [--foreground] [--force] [--non-interactive]',
|
|
77
|
+
' aiwg housekeeper stop [--non-interactive]',
|
|
78
|
+
' aiwg housekeeper status [--json] [--non-interactive]',
|
|
79
|
+
' aiwg housekeeper run-once --job <id> [--json] [--dry-run] [--fire-time <iso>] [--non-interactive]',
|
|
80
|
+
' aiwg housekeeper job list [--json]',
|
|
81
|
+
' aiwg housekeeper job register --id <id> --kind <kind> --schedule <cron> [--timezone <tz>] [--timeout-ms <n>] [--retries 0|1|2] [--non-interactive]',
|
|
82
|
+
' aiwg housekeeper job disable --id <id> [--non-interactive]',
|
|
83
|
+
' aiwg housekeeper watch list [--json]',
|
|
84
|
+
' aiwg housekeeper watch register --path <dir> [--events <a,b>] [--extensions <.ts>] [--non-interactive]',
|
|
85
|
+
' aiwg housekeeper watch disable --path <dir> [--non-interactive]',
|
|
86
|
+
' aiwg housekeeper install --systemd-user [--with-timer] [--dry-run] [--non-interactive]',
|
|
87
|
+
' aiwg housekeeper install --launchd [--dry-run] [--non-interactive]',
|
|
88
|
+
' aiwg housekeeper uninstall [--dry-run] [--non-interactive]',
|
|
89
|
+
'',
|
|
90
|
+
'Lifecycle:',
|
|
91
|
+
' start Run the housekeeper-profile daemon (scheduler, watcher, minimal IPC)',
|
|
92
|
+
' stop Graceful shutdown (SIGTERM, then SIGKILL) and pid/lock release',
|
|
93
|
+
' status Daemon, job and per-index freshness state; --json is machine-readable',
|
|
94
|
+
' run-once Run one due job without a daemon; prints its receipt and exits',
|
|
95
|
+
' job List, register or disable scheduled maintenance jobs',
|
|
96
|
+
' watch List, register or disable watched roots (change hints only)',
|
|
97
|
+
' install Write autostart units (explicit opt-in; never enabled implicitly)',
|
|
98
|
+
' uninstall Remove autostart units written by install',
|
|
99
|
+
'',
|
|
100
|
+
`Maintenance kinds: ${MAINTENANCE_KINDS.join(', ')}`,
|
|
101
|
+
'Update checks are check-only by default; installs need an explicit opt-in.',
|
|
102
|
+
'Memory audit/recommend: [--scope project|global|provider] [--provider <id>].',
|
|
103
|
+
'Memory recommend also accepts [--evidence <file>] [--usage <file>]; never creates plans.',
|
|
104
|
+
'Memory apply requires --plan <reviewed-file> --confirmed-digest <recorded-digest>.',
|
|
105
|
+
'It executes only the named reviewed plan; omitting --schedule registers a one-shot run-once job.',
|
|
106
|
+
'',
|
|
107
|
+
'Platforms: Linux + systemd (documented) · Linux w/o systemd and macOS',
|
|
108
|
+
'(foreground start, cron/launchd calling run-once; launchd plist best-effort).',
|
|
109
|
+
'Windows is not supported — use WSL2 and follow the Linux rows.',
|
|
110
|
+
'',
|
|
111
|
+
'Every value above can be passed as a flag; without one the command asks',
|
|
112
|
+
'only on an interactive terminal, otherwise it fails naming the flag.',
|
|
113
|
+
].join('\n');
|
|
114
|
+
}
|
|
115
|
+
async function runOnceSupportedOrThrow(platform) {
|
|
116
|
+
if (!runSupported(platform)) {
|
|
117
|
+
const error = new Error(runUnsupportedMessage());
|
|
118
|
+
error.code = 'UNSUPPORTED_PLATFORM';
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function parsePositiveInt(raw, flag) {
|
|
123
|
+
if (raw === undefined)
|
|
124
|
+
return undefined;
|
|
125
|
+
const n = Number.parseInt(raw, 10);
|
|
126
|
+
if (!Number.isSafeInteger(n) || n <= 0 || String(n) !== raw.trim()) {
|
|
127
|
+
throw new Error(`${flag} must be a positive integer (got '${raw}')`);
|
|
128
|
+
}
|
|
129
|
+
return n;
|
|
130
|
+
}
|
|
131
|
+
function parseCronExpression(raw) {
|
|
132
|
+
if (raw === undefined)
|
|
133
|
+
return undefined;
|
|
134
|
+
if (!/^\S+(\s+\S+){4}$/.test(raw.trim())) {
|
|
135
|
+
throw new Error(`--schedule must be a five-field cron expression (got '${raw}')`);
|
|
136
|
+
}
|
|
137
|
+
return raw.trim();
|
|
138
|
+
}
|
|
139
|
+
async function promptMissing(c, question, fallback = '') {
|
|
140
|
+
if (!c.interactive)
|
|
141
|
+
return null;
|
|
142
|
+
const rl = createPromptInterface();
|
|
143
|
+
try {
|
|
144
|
+
return await askString(rl, question, fallback, c.ctx.signal);
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
rl.close();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
async function requireValueOrPrompt(c, flag, current, question) {
|
|
151
|
+
if (current !== undefined && current !== '')
|
|
152
|
+
return current;
|
|
153
|
+
const answered = await promptMissing(c, question);
|
|
154
|
+
if (answered !== null && answered !== '')
|
|
155
|
+
return answered;
|
|
156
|
+
return {
|
|
157
|
+
error: usageError(`aiwg housekeeper: ${flag} requires a value.`, `Pass ${flag} <value> or run on an interactive terminal.`),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
// ── start ─────────────────────────────────────────────────────────
|
|
161
|
+
async function ensureHousekeeperConfig(c, force) {
|
|
162
|
+
const loaded = loadDaemonConfig(c.cwd);
|
|
163
|
+
if (!loaded.existed) {
|
|
164
|
+
saveDaemonConfig(loaded.path, seedHousekeeperConfig());
|
|
165
|
+
return { path: loaded.path };
|
|
166
|
+
}
|
|
167
|
+
const profile = getDaemonProfile(loaded.config);
|
|
168
|
+
if (profile === HOUSEKEEPER_PROFILE)
|
|
169
|
+
return { path: loaded.path };
|
|
170
|
+
const label = profile === undefined ? 'no daemon.profile' : `daemon.profile '${profile}'`;
|
|
171
|
+
if (!force) {
|
|
172
|
+
if (c.interactive) {
|
|
173
|
+
const rl = createPromptInterface();
|
|
174
|
+
try {
|
|
175
|
+
const ok = await askYesNo(rl, ` Daemon config has ${label}; switch it to '${HOUSEKEEPER_PROFILE}'? [y/N]: `, false, c.ctx.signal);
|
|
176
|
+
if (!ok) {
|
|
177
|
+
return { error: { exitCode: EXIT_CODES.GENERAL, message: 'aiwg housekeeper: start aborted; daemon config left unchanged.' } };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
rl.close();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
return {
|
|
186
|
+
error: usageError(`aiwg housekeeper: refusing to change daemon config (${label}); pass --force to switch it to '${HOUSEKEEPER_PROFILE}'.`, `Pass --force or run on an interactive terminal to confirm.`),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
setDaemonProfile(loaded.config, HOUSEKEEPER_PROFILE);
|
|
191
|
+
saveDaemonConfig(loaded.path, loaded.config);
|
|
192
|
+
return { path: loaded.path };
|
|
193
|
+
}
|
|
194
|
+
function spawnForegroundDaemon(c, paths) {
|
|
195
|
+
return new Promise((resolve) => {
|
|
196
|
+
const child = spawn(process.execPath, [paths.daemonScript], {
|
|
197
|
+
stdio: 'inherit',
|
|
198
|
+
cwd: c.cwd,
|
|
199
|
+
env: process.env,
|
|
200
|
+
});
|
|
201
|
+
const forward = (signal) => {
|
|
202
|
+
signalChild(child, signal);
|
|
203
|
+
};
|
|
204
|
+
const onSigterm = () => { forward('SIGTERM'); };
|
|
205
|
+
const onSigint = () => { forward('SIGINT'); };
|
|
206
|
+
process.once('SIGTERM', onSigterm);
|
|
207
|
+
process.once('SIGINT', onSigint);
|
|
208
|
+
const abort = () => { forward('SIGTERM'); };
|
|
209
|
+
c.ctx.signal?.addEventListener('abort', abort, { once: true });
|
|
210
|
+
child.once('error', () => {
|
|
211
|
+
process.removeListener('SIGTERM', onSigterm);
|
|
212
|
+
process.removeListener('SIGINT', onSigint);
|
|
213
|
+
c.ctx.signal?.removeEventListener('abort', abort);
|
|
214
|
+
resolve(1);
|
|
215
|
+
});
|
|
216
|
+
child.once('exit', (code) => {
|
|
217
|
+
process.removeListener('SIGTERM', onSigterm);
|
|
218
|
+
process.removeListener('SIGINT', onSigint);
|
|
219
|
+
c.ctx.signal?.removeEventListener('abort', abort);
|
|
220
|
+
resolve(code ?? 0);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
async function executeStart(c) {
|
|
225
|
+
const unknown = checkUnknownFlags(c.args, 'start');
|
|
226
|
+
if (unknown)
|
|
227
|
+
return unknown;
|
|
228
|
+
const foreground = hasSwitch(c.args, '--foreground');
|
|
229
|
+
const force = hasSwitch(c.args, '--force', '--yes', '-y');
|
|
230
|
+
try {
|
|
231
|
+
await runOnceSupportedOrThrow(process.platform);
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
return handlerResultFromError(error);
|
|
235
|
+
}
|
|
236
|
+
const paths = daemonPaths(c.cwd, c.frameworkRoot);
|
|
237
|
+
const state = describeRunning(paths.pidFile);
|
|
238
|
+
if (state.running) {
|
|
239
|
+
return {
|
|
240
|
+
exitCode: EXIT_CODES.GENERAL,
|
|
241
|
+
message: `aiwg housekeeper: daemon is already running (PID ${state.pid}). Stop it first with 'aiwg housekeeper stop'.`,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
const ensured = await ensureHousekeeperConfig(c, force);
|
|
245
|
+
if ('error' in ensured)
|
|
246
|
+
return ensured.error;
|
|
247
|
+
if (state.stale)
|
|
248
|
+
removeLifecycleFiles(paths);
|
|
249
|
+
if (foreground) {
|
|
250
|
+
const code = await spawnForegroundDaemon(c, paths);
|
|
251
|
+
return { exitCode: code };
|
|
252
|
+
}
|
|
253
|
+
fs.mkdirSync(path.dirname(paths.logFile), { recursive: true });
|
|
254
|
+
let logFd;
|
|
255
|
+
try {
|
|
256
|
+
logFd = fs.openSync(paths.logFile, 'a');
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
return handlerResultFromError(error);
|
|
260
|
+
}
|
|
261
|
+
let child;
|
|
262
|
+
try {
|
|
263
|
+
child = spawn(process.execPath, [paths.daemonScript], {
|
|
264
|
+
detached: true,
|
|
265
|
+
stdio: ['ignore', logFd, logFd],
|
|
266
|
+
cwd: c.cwd,
|
|
267
|
+
env: process.env,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
fs.closeSync(logFd);
|
|
272
|
+
return handlerResultFromError(error);
|
|
273
|
+
}
|
|
274
|
+
child.unref();
|
|
275
|
+
fs.closeSync(logFd);
|
|
276
|
+
const appeared = await pollUntil(() => describeRunning(paths.pidFile).running, { timeoutMs: 15_000 });
|
|
277
|
+
if (!appeared) {
|
|
278
|
+
return {
|
|
279
|
+
exitCode: EXIT_CODES.GENERAL,
|
|
280
|
+
message: `aiwg housekeeper: daemon failed to start. See ${paths.logFile} for details.`,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
const pid = describeRunning(paths.pidFile).pid;
|
|
284
|
+
return { exitCode: EXIT_CODES.OK, message: `Housekeeper daemon started (PID ${pid}). Logs: ${paths.logFile}` };
|
|
285
|
+
}
|
|
286
|
+
// ── stop ──────────────────────────────────────────────────────────
|
|
287
|
+
async function executeStop(c) {
|
|
288
|
+
const unknown = checkUnknownFlags(c.args, 'stop');
|
|
289
|
+
if (unknown)
|
|
290
|
+
return unknown;
|
|
291
|
+
const paths = daemonPaths(c.cwd, c.frameworkRoot);
|
|
292
|
+
const outcome = await stopDaemon({ paths });
|
|
293
|
+
if (outcome.outcome === 'not-running') {
|
|
294
|
+
return { exitCode: EXIT_CODES.OK, message: 'Housekeeper daemon is not running' };
|
|
295
|
+
}
|
|
296
|
+
if (outcome.outcome === 'stale-cleaned') {
|
|
297
|
+
return { exitCode: EXIT_CODES.OK, message: `Removed stale housekeeper pid/lock files (dead PID ${outcome.pid})` };
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
exitCode: EXIT_CODES.OK,
|
|
301
|
+
message: outcome.forced
|
|
302
|
+
? `Housekeeper daemon (PID ${outcome.pid}) did not stop gracefully; sent SIGKILL`
|
|
303
|
+
: `Housekeeper daemon (PID ${outcome.pid}) stopped gracefully`,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
// ── status ────────────────────────────────────────────────────────
|
|
307
|
+
/** Offline jobs: config entries joined with persisted receipts (fail-soft). */
|
|
308
|
+
export function readLocalJobs(cwd, config) {
|
|
309
|
+
const jobs = getScheduleJobs(config);
|
|
310
|
+
let schedules = [];
|
|
311
|
+
try {
|
|
312
|
+
const raw = fs.readFileSync(path.join(cwd, '.aiwg', 'daemon', 'tasks.json'), 'utf8');
|
|
313
|
+
const parsed = JSON.parse(raw);
|
|
314
|
+
if (parsed && typeof parsed === 'object' && Array.isArray(parsed.schedules)) {
|
|
315
|
+
schedules = parsed.schedules;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
schedules = [];
|
|
320
|
+
}
|
|
321
|
+
const byId = new Map(schedules.filter((s) => s?.job?.id).map((s) => [s.job.id, s]));
|
|
322
|
+
return jobs.map((job) => {
|
|
323
|
+
const record = byId.get(job.id);
|
|
324
|
+
return {
|
|
325
|
+
id: job.id,
|
|
326
|
+
kind: job.kind ?? null,
|
|
327
|
+
lastState: normalizeJobState(record?.lastReceipt?.state),
|
|
328
|
+
lastRun: record?.lastRun ?? null,
|
|
329
|
+
nextFire: record?.nextRun ?? null,
|
|
330
|
+
};
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
export function readLocalLastCheck(cwd, config) {
|
|
334
|
+
void config;
|
|
335
|
+
try {
|
|
336
|
+
const raw = fs.readFileSync(path.join(cwd, '.aiwg', 'daemon', 'tasks.json'), 'utf8');
|
|
337
|
+
const parsed = JSON.parse(raw);
|
|
338
|
+
const checks = (parsed.schedules ?? [])
|
|
339
|
+
.filter((s) => s?.job?.kind === 'update.check')
|
|
340
|
+
.map((s) => s.lastReceipt?.finishedAt)
|
|
341
|
+
.filter((v) => typeof v === 'string');
|
|
342
|
+
return checks.length ? checks.sort().pop() : null;
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
/** Offline install outcome: the latest update.install receipt, if any. */
|
|
349
|
+
export function readLocalLastInstall(cwd) {
|
|
350
|
+
try {
|
|
351
|
+
const raw = fs.readFileSync(path.join(cwd, '.aiwg', 'daemon', 'tasks.json'), 'utf8');
|
|
352
|
+
const parsed = JSON.parse(raw);
|
|
353
|
+
const installs = (parsed.schedules ?? [])
|
|
354
|
+
.filter((s) => s?.job?.kind === 'update.install' && typeof s.lastReceipt?.finishedAt === 'string')
|
|
355
|
+
.sort((a, b) => (a.lastReceipt.finishedAt < b.lastReceipt.finishedAt ? -1 : 1));
|
|
356
|
+
const last = installs.pop()?.lastReceipt;
|
|
357
|
+
if (!last)
|
|
358
|
+
return null;
|
|
359
|
+
return {
|
|
360
|
+
state: typeof last.state === 'string' ? last.state : 'unknown',
|
|
361
|
+
reason: typeof last.reason === 'string' ? last.reason : null,
|
|
362
|
+
finishedAt: typeof last.finishedAt === 'string' ? last.finishedAt : null,
|
|
363
|
+
summary: typeof last.summary === 'string' ? last.summary : null,
|
|
364
|
+
oldVersion: typeof last.detail?.oldVersion === 'string' ? last.detail.oldVersion : null,
|
|
365
|
+
newVersion: typeof last.detail?.newVersion === 'string' ? last.detail.newVersion : null,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
/** Offline update availability from the latest update.check receipt detail. */
|
|
373
|
+
export function readLocalUpdateAvailable(cwd) {
|
|
374
|
+
try {
|
|
375
|
+
const raw = fs.readFileSync(path.join(cwd, '.aiwg', 'daemon', 'tasks.json'), 'utf8');
|
|
376
|
+
const parsed = JSON.parse(raw);
|
|
377
|
+
for (const s of parsed.schedules ?? []) {
|
|
378
|
+
if (s?.job?.kind !== 'update.check')
|
|
379
|
+
continue;
|
|
380
|
+
const available = s.lastReceipt?.detail?.available;
|
|
381
|
+
if (typeof available === 'boolean')
|
|
382
|
+
return available;
|
|
383
|
+
}
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
function normalizeInstallOutcome(value) {
|
|
391
|
+
if (!value || typeof value !== 'object')
|
|
392
|
+
return null;
|
|
393
|
+
const record = value;
|
|
394
|
+
const str = (v) => (typeof v === 'string' ? v : null);
|
|
395
|
+
if (str(record['finishedAt']) === null && str(record['summary']) === null)
|
|
396
|
+
return null;
|
|
397
|
+
return {
|
|
398
|
+
state: str(record['state']) ?? 'unknown',
|
|
399
|
+
reason: str(record['reason']),
|
|
400
|
+
finishedAt: str(record['finishedAt']),
|
|
401
|
+
summary: str(record['summary']),
|
|
402
|
+
oldVersion: str(record['oldVersion']),
|
|
403
|
+
newVersion: str(record['newVersion']),
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
async function collectFreshness(cwd) {
|
|
407
|
+
try {
|
|
408
|
+
const { probeIndexStatus } = await import('../../artifacts/index-status.js');
|
|
409
|
+
const report = await probeIndexStatus(cwd);
|
|
410
|
+
return (report.indices ?? []).map(toFreshnessEntry);
|
|
411
|
+
}
|
|
412
|
+
catch {
|
|
413
|
+
return [];
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
function normalizeIpcJobs(value) {
|
|
417
|
+
if (!Array.isArray(value))
|
|
418
|
+
return null;
|
|
419
|
+
return value.map((entry) => {
|
|
420
|
+
const record = (entry ?? {});
|
|
421
|
+
return {
|
|
422
|
+
id: typeof record['id'] === 'string' ? record['id'] : '?',
|
|
423
|
+
kind: typeof record['kind'] === 'string' ? record['kind'] : null,
|
|
424
|
+
lastState: normalizeJobState(record['lastState']),
|
|
425
|
+
lastRun: typeof record['lastRun'] === 'string' ? record['lastRun'] : null,
|
|
426
|
+
nextFire: typeof record['nextFire'] === 'string' ? record['nextFire'] : null,
|
|
427
|
+
};
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
function normalizeIpcFreshness(value) {
|
|
431
|
+
if (!Array.isArray(value))
|
|
432
|
+
return null;
|
|
433
|
+
return value.map((entry) => {
|
|
434
|
+
const record = (entry ?? {});
|
|
435
|
+
const index = typeof record['index'] === 'string' ? record['index'] : '?';
|
|
436
|
+
return toFreshnessEntry({
|
|
437
|
+
name: index,
|
|
438
|
+
status: typeof record['state'] === 'string' ? record['state'] : 'unsupported',
|
|
439
|
+
policy: typeof record['targetMs'] === 'number' ? { targetStaleAfterMs: record['targetMs'] } : {},
|
|
440
|
+
convergence: typeof record['converged'] === 'boolean' ? { converged: record['converged'] } : {},
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
async function executeStatus(c) {
|
|
445
|
+
const unknown = checkUnknownFlags(c.args, 'status');
|
|
446
|
+
if (unknown)
|
|
447
|
+
return unknown;
|
|
448
|
+
const json = hasSwitch(c.args, '--json');
|
|
449
|
+
const paths = daemonPaths(c.cwd, c.frameworkRoot);
|
|
450
|
+
const loaded = loadDaemonConfig(c.cwd);
|
|
451
|
+
const live = describeRunning(paths.pidFile).running;
|
|
452
|
+
let mode = 'offline';
|
|
453
|
+
let pid = null;
|
|
454
|
+
let uptimeSeconds = 0;
|
|
455
|
+
let jobs = null;
|
|
456
|
+
let freshness = null;
|
|
457
|
+
let lastCheck = null;
|
|
458
|
+
let available = null;
|
|
459
|
+
let lastInstall = null;
|
|
460
|
+
if (live) {
|
|
461
|
+
mode = 'daemon';
|
|
462
|
+
pid = describeRunning(paths.pidFile).pid;
|
|
463
|
+
try {
|
|
464
|
+
const result = (await requestDaemon(paths.socketPath, 'housekeeper.status'));
|
|
465
|
+
jobs = normalizeIpcJobs(result['jobs']) ?? readLocalJobs(c.cwd, loaded.config);
|
|
466
|
+
freshness = normalizeIpcFreshness(result['freshness']);
|
|
467
|
+
uptimeSeconds = typeof result['uptimeSeconds'] === 'number' ? result['uptimeSeconds'] : 0;
|
|
468
|
+
const update = result['update'];
|
|
469
|
+
lastCheck = typeof update?.['lastCheck'] === 'string' ? update['lastCheck'] : null;
|
|
470
|
+
available = typeof update?.['available'] === 'boolean' ? update['available'] : null;
|
|
471
|
+
lastInstall = normalizeInstallOutcome(update?.['lastInstall']) ?? readLocalLastInstall(c.cwd);
|
|
472
|
+
}
|
|
473
|
+
catch {
|
|
474
|
+
try {
|
|
475
|
+
const result = (await requestDaemon(paths.socketPath, 'daemon.status'));
|
|
476
|
+
uptimeSeconds = typeof result['uptime_seconds'] === 'number' ? result['uptime_seconds'] : 0;
|
|
477
|
+
}
|
|
478
|
+
catch { /* IPC unavailable; report what the pid file proves */ }
|
|
479
|
+
jobs = readLocalJobs(c.cwd, loaded.config);
|
|
480
|
+
lastCheck = readLocalLastCheck(c.cwd, loaded.config);
|
|
481
|
+
available = readLocalUpdateAvailable(c.cwd);
|
|
482
|
+
lastInstall = readLocalLastInstall(c.cwd);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
else {
|
|
486
|
+
jobs = readLocalJobs(c.cwd, loaded.config);
|
|
487
|
+
lastCheck = readLocalLastCheck(c.cwd, loaded.config);
|
|
488
|
+
available = readLocalUpdateAvailable(c.cwd);
|
|
489
|
+
lastInstall = readLocalLastInstall(c.cwd);
|
|
490
|
+
}
|
|
491
|
+
freshness ??= await collectFreshness(c.cwd);
|
|
492
|
+
const envelope = buildHousekeeperStatus({ mode, pid, uptimeSeconds, jobs, freshness, lastCheck, available, lastInstall });
|
|
493
|
+
if (json) {
|
|
494
|
+
return { exitCode: EXIT_CODES.OK, message: JSON.stringify(envelope, null, 2), rawOutput: true };
|
|
495
|
+
}
|
|
496
|
+
const lines = [
|
|
497
|
+
`Housekeeper: ${mode === 'daemon' ? `running (PID ${pid})` : 'stopped'}`,
|
|
498
|
+
`Jobs: ${envelope.totals.jobs} (ok ${envelope.totals.ok}, failed ${envelope.totals.failed}, ` +
|
|
499
|
+
`skipped ${envelope.totals.skipped}, deferred ${envelope.totals.deferred}, never ${envelope.totals.never})`,
|
|
500
|
+
];
|
|
501
|
+
for (const job of envelope.jobs) {
|
|
502
|
+
lines.push(` ${job.id} [${job.kind ?? '?'}]: ${job.lastState}${job.lastRun ? ` last ${job.lastRun}` : ''}${job.nextFire ? ` next ${job.nextFire}` : ''}`);
|
|
503
|
+
}
|
|
504
|
+
lines.push(`Freshness: ${envelope.freshness.length} indice(s)`);
|
|
505
|
+
for (const entry of envelope.freshness) {
|
|
506
|
+
lines.push(` ${entry.index}: ${entry.state}`);
|
|
507
|
+
}
|
|
508
|
+
if (lastInstall) {
|
|
509
|
+
const versions = lastInstall.oldVersion && lastInstall.newVersion
|
|
510
|
+
? ` ${lastInstall.oldVersion} → ${lastInstall.newVersion}` : '';
|
|
511
|
+
lines.push(`Last install: ${lastInstall.state}${versions}${lastInstall.finishedAt ? ` at ${lastInstall.finishedAt}` : ''}`);
|
|
512
|
+
if (lastInstall.summary)
|
|
513
|
+
lines.push(` ${lastInstall.summary}`);
|
|
514
|
+
}
|
|
515
|
+
return { exitCode: EXIT_CODES.OK, message: lines.join('\n') };
|
|
516
|
+
}
|
|
517
|
+
// ── run-once ──────────────────────────────────────────────────────
|
|
518
|
+
async function selectConfiguredJob(c, jobs) {
|
|
519
|
+
if (jobs.length === 0) {
|
|
520
|
+
return { error: usageError('aiwg housekeeper: no jobs configured.', 'Register one with `aiwg housekeeper job register`.') };
|
|
521
|
+
}
|
|
522
|
+
if (!c.interactive) {
|
|
523
|
+
return {
|
|
524
|
+
error: usageError('aiwg housekeeper: --job requires a value.', `Pass --job <id>; known jobs: ${jobs.map((job) => job.id).join(', ')}.`),
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
const rl = createPromptInterface();
|
|
528
|
+
try {
|
|
529
|
+
const picked = await listSelect(rl, ' Job: ', jobs.map((job) => ({ label: `${job.id} [${job.kind}]`, value: job.id })), jobs[0].id, c.ctx.signal);
|
|
530
|
+
return picked;
|
|
531
|
+
}
|
|
532
|
+
finally {
|
|
533
|
+
rl.close();
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
async function executeRunOnce(c) {
|
|
537
|
+
const unknown = checkUnknownFlags(c.args, 'run-once');
|
|
538
|
+
if (unknown)
|
|
539
|
+
return unknown;
|
|
540
|
+
let jobId;
|
|
541
|
+
try {
|
|
542
|
+
jobId = flagValue(c.args, '--job');
|
|
543
|
+
}
|
|
544
|
+
catch (error) {
|
|
545
|
+
if (error instanceof MissingFlagValueError) {
|
|
546
|
+
return usageError(`aiwg housekeeper: ${error.message}.`, 'Pass --job <id>.');
|
|
547
|
+
}
|
|
548
|
+
throw error;
|
|
549
|
+
}
|
|
550
|
+
const json = hasSwitch(c.args, '--json');
|
|
551
|
+
const dryRun = hasSwitch(c.args, '--dry-run');
|
|
552
|
+
let fireTime;
|
|
553
|
+
try {
|
|
554
|
+
fireTime = flagValue(c.args, '--fire-time');
|
|
555
|
+
}
|
|
556
|
+
catch (error) {
|
|
557
|
+
if (error instanceof MissingFlagValueError) {
|
|
558
|
+
return usageError(`aiwg housekeeper: ${error.message}.`, 'Pass --fire-time <iso-timestamp>.');
|
|
559
|
+
}
|
|
560
|
+
throw error;
|
|
561
|
+
}
|
|
562
|
+
try {
|
|
563
|
+
await runOnceSupportedOrThrow(process.platform);
|
|
564
|
+
}
|
|
565
|
+
catch (error) {
|
|
566
|
+
return handlerResultFromError(error);
|
|
567
|
+
}
|
|
568
|
+
const loaded = loadDaemonConfig(c.cwd);
|
|
569
|
+
const jobs = getScheduleJobs(loaded.config);
|
|
570
|
+
if (jobId === undefined) {
|
|
571
|
+
const picked = await selectConfiguredJob(c, jobs);
|
|
572
|
+
if (typeof picked !== 'string')
|
|
573
|
+
return picked.error;
|
|
574
|
+
jobId = picked;
|
|
575
|
+
}
|
|
576
|
+
const job = jobs.find((entry) => entry.id === jobId);
|
|
577
|
+
if (!job) {
|
|
578
|
+
return usageError(`aiwg housekeeper: unknown job '${jobId}'.`, jobs.length ? `Known jobs: ${jobs.map((entry) => entry.id).join(', ')}.` : 'Register one with `aiwg housekeeper job register`.');
|
|
579
|
+
}
|
|
580
|
+
if (dryRun) {
|
|
581
|
+
return {
|
|
582
|
+
exitCode: EXIT_CODES.OK,
|
|
583
|
+
message: `Would run job '${job.id}' (kind ${job.kind}, schedule ${job.cron ?? job.schedule ?? 'manual'}). No daemon, no writes.`,
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
// Reuse the B2 manual path: no watcher, no scheduler, no IPC server.
|
|
587
|
+
const runner = createScriptRunner(c.frameworkRoot);
|
|
588
|
+
const scriptArgs = ['schedule', '--run-once', '--job', job.id];
|
|
589
|
+
if (json)
|
|
590
|
+
scriptArgs.push('--json');
|
|
591
|
+
if (fireTime !== undefined)
|
|
592
|
+
scriptArgs.push('--fire-time', fireTime);
|
|
593
|
+
return runner.run('tools/daemon/index.mjs', scriptArgs);
|
|
594
|
+
}
|
|
595
|
+
// ── job ───────────────────────────────────────────────────────────
|
|
596
|
+
function formatJobsHuman(jobs) {
|
|
597
|
+
if (jobs.length === 0)
|
|
598
|
+
return 'No housekeeper jobs configured';
|
|
599
|
+
const lines = [`Jobs: ${jobs.length}`];
|
|
600
|
+
for (const job of jobs) {
|
|
601
|
+
lines.push(` ${job.id} [${job.kind}] ${job.cron ?? job.schedule ?? 'manual'}${job.timezone ? ` (${job.timezone})` : ''}`);
|
|
602
|
+
}
|
|
603
|
+
return lines.join('\n');
|
|
604
|
+
}
|
|
605
|
+
async function executeJobRegister(c, rest) {
|
|
606
|
+
const unknown = checkUnknownFlags(rest, 'job register');
|
|
607
|
+
if (unknown)
|
|
608
|
+
return unknown;
|
|
609
|
+
let id;
|
|
610
|
+
let kind;
|
|
611
|
+
let schedule;
|
|
612
|
+
let timezone;
|
|
613
|
+
let timeoutMsRaw;
|
|
614
|
+
let retriesRaw;
|
|
615
|
+
try {
|
|
616
|
+
id = flagValue(rest, '--id');
|
|
617
|
+
kind = flagValue(rest, '--kind');
|
|
618
|
+
schedule = flagValue(rest, '--schedule', '--cron');
|
|
619
|
+
timezone = flagValue(rest, '--timezone');
|
|
620
|
+
timeoutMsRaw = flagValue(rest, '--timeout-ms');
|
|
621
|
+
retriesRaw = flagValue(rest, '--retries');
|
|
622
|
+
}
|
|
623
|
+
catch (error) {
|
|
624
|
+
if (error instanceof MissingFlagValueError) {
|
|
625
|
+
return usageError(`aiwg housekeeper: ${error.message}.`, 'Run `aiwg housekeeper --help` for usage.');
|
|
626
|
+
}
|
|
627
|
+
throw error;
|
|
628
|
+
}
|
|
629
|
+
const resolvedId = await requireValueOrPrompt(c, '--id', id, ' Job id: ');
|
|
630
|
+
if (typeof resolvedId !== 'string')
|
|
631
|
+
return resolvedId.error;
|
|
632
|
+
const resolvedKind = await requireValueOrPrompt(c, '--kind', kind, ` Kind (${MAINTENANCE_KINDS.join('|')}): `);
|
|
633
|
+
if (typeof resolvedKind !== 'string')
|
|
634
|
+
return resolvedKind.error;
|
|
635
|
+
const resolvedSchedule = resolvedKind === 'memory.apply' && schedule === undefined ? ''
|
|
636
|
+
: await requireValueOrPrompt(c, '--schedule', schedule, ' Cron schedule (five fields, e.g. "0 * * * *"): ');
|
|
637
|
+
if (typeof resolvedSchedule !== 'string')
|
|
638
|
+
return resolvedSchedule.error;
|
|
639
|
+
if (!MAINTENANCE_KINDS.includes(resolvedKind)) {
|
|
640
|
+
return usageError(`aiwg housekeeper: unknown kind '${resolvedKind}'.`, `Known kinds: ${MAINTENANCE_KINDS.join(', ')}.`);
|
|
641
|
+
}
|
|
642
|
+
let cron;
|
|
643
|
+
try {
|
|
644
|
+
cron = resolvedSchedule ? parseCronExpression(resolvedSchedule) : undefined;
|
|
645
|
+
}
|
|
646
|
+
catch (error) {
|
|
647
|
+
return usageError(`aiwg housekeeper: ${error.message}.`, 'Example: --schedule "0 * * * *".');
|
|
648
|
+
}
|
|
649
|
+
let timeoutMs;
|
|
650
|
+
let retries;
|
|
651
|
+
try {
|
|
652
|
+
timeoutMs = timeoutMsRaw === undefined ? undefined : parsePositiveInt(timeoutMsRaw, '--timeout-ms');
|
|
653
|
+
if (retriesRaw !== undefined) {
|
|
654
|
+
const n = Number.parseInt(retriesRaw, 10);
|
|
655
|
+
if (![0, 1, 2].includes(n) || String(n) !== retriesRaw.trim()) {
|
|
656
|
+
throw new Error(`--retries must be 0, 1, or 2 (got '${retriesRaw}')`);
|
|
657
|
+
}
|
|
658
|
+
retries = n;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
catch (error) {
|
|
662
|
+
return usageError(`aiwg housekeeper: ${error.message}.`, 'Run `aiwg housekeeper --help` for usage.');
|
|
663
|
+
}
|
|
664
|
+
const loaded = loadDaemonConfig(c.cwd);
|
|
665
|
+
const jobs = getScheduleJobs(loaded.config);
|
|
666
|
+
const entry = { id: resolvedId, kind: resolvedKind, cron };
|
|
667
|
+
if (resolvedKind.startsWith('memory.')) {
|
|
668
|
+
const args = {};
|
|
669
|
+
try {
|
|
670
|
+
for (const [key, flag] of [['scope', '--scope'], ['provider', '--provider'], ['evidence', '--evidence'],
|
|
671
|
+
['usage', '--usage'], ['plan', '--plan'], ['confirmedDigest', '--confirmed-digest']]) {
|
|
672
|
+
const value = flagValue(rest, flag);
|
|
673
|
+
if (value !== undefined)
|
|
674
|
+
args[key] = value;
|
|
675
|
+
}
|
|
676
|
+
validateMemoryJobArgs(resolvedKind, args);
|
|
677
|
+
}
|
|
678
|
+
catch (error) {
|
|
679
|
+
return usageError(`aiwg housekeeper: ${error.message}.`);
|
|
680
|
+
}
|
|
681
|
+
entry.args = args;
|
|
682
|
+
}
|
|
683
|
+
if (timezone !== undefined)
|
|
684
|
+
entry.timezone = timezone;
|
|
685
|
+
if (timeoutMs !== undefined)
|
|
686
|
+
entry.timeoutMs = timeoutMs;
|
|
687
|
+
if (retries !== undefined)
|
|
688
|
+
entry.retries = retries;
|
|
689
|
+
const existing = jobs.findIndex((job) => job.id === resolvedId);
|
|
690
|
+
// Registration has no window-edit flag: retain an existing apply restriction.
|
|
691
|
+
if (resolvedKind === 'memory.apply' && existing >= 0 && jobs[existing].kind === 'memory.apply'
|
|
692
|
+
&& jobs[existing].args?.windows !== undefined)
|
|
693
|
+
entry.args.windows = jobs[existing].args.windows;
|
|
694
|
+
if (existing >= 0)
|
|
695
|
+
jobs[existing] = { ...jobs[existing], ...entry, ...(resolvedKind.startsWith('memory.') ? { schedule: undefined } : {}) };
|
|
696
|
+
else
|
|
697
|
+
jobs.push(entry);
|
|
698
|
+
setScheduleJobs(loaded.config, jobs);
|
|
699
|
+
saveDaemonConfig(loaded.path, loaded.config);
|
|
700
|
+
return {
|
|
701
|
+
exitCode: EXIT_CODES.OK,
|
|
702
|
+
message: `${existing >= 0 ? 'Updated' : 'Registered'} job '${resolvedId}' (kind ${resolvedKind}, schedule ${cron ?? 'manual one-shot'}) in ${loaded.path}`
|
|
703
|
+
+ (resolvedKind === 'memory.apply' ? `\nExecutes only the named reviewed plan '${entry.args.plan}' with recorded digest ${entry.args.confirmedDigest}; scheduling grants no additional mutation authority.` : ''),
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
async function executeJobDisable(c, rest) {
|
|
707
|
+
const unknown = checkUnknownFlags(rest, 'job disable');
|
|
708
|
+
if (unknown)
|
|
709
|
+
return unknown;
|
|
710
|
+
let id;
|
|
711
|
+
try {
|
|
712
|
+
id = flagValue(rest, '--id');
|
|
713
|
+
}
|
|
714
|
+
catch (error) {
|
|
715
|
+
if (error instanceof MissingFlagValueError) {
|
|
716
|
+
return usageError(`aiwg housekeeper: ${error.message}.`, 'Pass --id <job-id>.');
|
|
717
|
+
}
|
|
718
|
+
throw error;
|
|
719
|
+
}
|
|
720
|
+
const loaded = loadDaemonConfig(c.cwd);
|
|
721
|
+
const jobs = getScheduleJobs(loaded.config);
|
|
722
|
+
if (id === undefined) {
|
|
723
|
+
if (jobs.length === 0) {
|
|
724
|
+
return usageError('aiwg housekeeper: no jobs configured.', 'Nothing to disable.');
|
|
725
|
+
}
|
|
726
|
+
if (!c.interactive) {
|
|
727
|
+
return usageError('aiwg housekeeper: --id requires a value.', `Pass --id <job-id>; known jobs: ${jobs.map((job) => job.id).join(', ')}.`);
|
|
728
|
+
}
|
|
729
|
+
const rl = createPromptInterface();
|
|
730
|
+
try {
|
|
731
|
+
id = await listSelect(rl, ' Disable job: ', jobs.map((job) => ({ label: `${job.id} [${job.kind}]`, value: job.id })), jobs[0].id, c.ctx.signal);
|
|
732
|
+
}
|
|
733
|
+
finally {
|
|
734
|
+
rl.close();
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
const remaining = jobs.filter((job) => job.id !== id);
|
|
738
|
+
if (remaining.length === jobs.length) {
|
|
739
|
+
return usageError(`aiwg housekeeper: unknown job '${id}'.`, `Known jobs: ${jobs.map((job) => job.id).join(', ') || '(none)'}.`);
|
|
740
|
+
}
|
|
741
|
+
setScheduleJobs(loaded.config, remaining);
|
|
742
|
+
saveDaemonConfig(loaded.path, loaded.config);
|
|
743
|
+
return { exitCode: EXIT_CODES.OK, message: `Disabled job '${id}' (removed from ${loaded.path})` };
|
|
744
|
+
}
|
|
745
|
+
async function executeJob(c, rest) {
|
|
746
|
+
const [verb = 'list', ...verbArgs] = rest;
|
|
747
|
+
if (verb === 'list') {
|
|
748
|
+
const unknown = checkUnknownFlags(verbArgs, 'job list');
|
|
749
|
+
if (unknown)
|
|
750
|
+
return unknown;
|
|
751
|
+
const loaded = loadDaemonConfig(c.cwd);
|
|
752
|
+
const jobs = getScheduleJobs(loaded.config);
|
|
753
|
+
if (hasSwitch(verbArgs, '--json')) {
|
|
754
|
+
return { exitCode: EXIT_CODES.OK, message: JSON.stringify({ jobs }, null, 2), rawOutput: true };
|
|
755
|
+
}
|
|
756
|
+
return { exitCode: EXIT_CODES.OK, message: formatJobsHuman(jobs) };
|
|
757
|
+
}
|
|
758
|
+
if (verb === 'register')
|
|
759
|
+
return executeJobRegister(c, verbArgs);
|
|
760
|
+
if (verb === 'disable')
|
|
761
|
+
return executeJobDisable(c, verbArgs);
|
|
762
|
+
return usageError(`aiwg housekeeper: unknown job subcommand '${verb}'.`, 'Use `job list|register|disable`.');
|
|
763
|
+
}
|
|
764
|
+
// ── watch ─────────────────────────────────────────────────────────
|
|
765
|
+
function formatWatchHuman(entries) {
|
|
766
|
+
if (entries.length === 0)
|
|
767
|
+
return 'No watch roots configured';
|
|
768
|
+
const lines = [`Watch roots: ${entries.length}`];
|
|
769
|
+
for (const entry of entries) {
|
|
770
|
+
lines.push(` ${entry.path}${entry.events?.length ? ` (${entry.events.join(',')})` : ''}`);
|
|
771
|
+
}
|
|
772
|
+
return lines.join('\n');
|
|
773
|
+
}
|
|
774
|
+
function parseCsvFlag(raw) {
|
|
775
|
+
if (raw === undefined)
|
|
776
|
+
return undefined;
|
|
777
|
+
const values = raw.split(',').map((entry) => entry.trim()).filter(Boolean);
|
|
778
|
+
if (values.length === 0)
|
|
779
|
+
throw new Error('Expected a comma-separated list with at least one entry');
|
|
780
|
+
return values;
|
|
781
|
+
}
|
|
782
|
+
async function executeWatchRegister(c, rest) {
|
|
783
|
+
const unknown = checkUnknownFlags(rest, 'watch register');
|
|
784
|
+
if (unknown)
|
|
785
|
+
return unknown;
|
|
786
|
+
let dir;
|
|
787
|
+
let eventsRaw;
|
|
788
|
+
let extensionsRaw;
|
|
789
|
+
try {
|
|
790
|
+
dir = flagValue(rest, '--path');
|
|
791
|
+
eventsRaw = flagValue(rest, '--events');
|
|
792
|
+
extensionsRaw = flagValue(rest, '--extensions');
|
|
793
|
+
}
|
|
794
|
+
catch (error) {
|
|
795
|
+
if (error instanceof MissingFlagValueError) {
|
|
796
|
+
return usageError(`aiwg housekeeper: ${error.message}.`, 'Run `aiwg housekeeper --help` for usage.');
|
|
797
|
+
}
|
|
798
|
+
throw error;
|
|
799
|
+
}
|
|
800
|
+
const resolved = await requireValueOrPrompt(c, '--path', dir, ' Watch path: ');
|
|
801
|
+
if (typeof resolved !== 'string')
|
|
802
|
+
return resolved.error;
|
|
803
|
+
let events;
|
|
804
|
+
let extensions;
|
|
805
|
+
try {
|
|
806
|
+
events = parseCsvFlag(eventsRaw);
|
|
807
|
+
extensions = parseCsvFlag(extensionsRaw);
|
|
808
|
+
}
|
|
809
|
+
catch (error) {
|
|
810
|
+
return usageError(`aiwg housekeeper: ${error.message}.`, 'Example: --events create,modify,delete.');
|
|
811
|
+
}
|
|
812
|
+
const loaded = loadDaemonConfig(c.cwd);
|
|
813
|
+
const entries = getWatchPaths(loaded.config);
|
|
814
|
+
const entry = { path: resolved };
|
|
815
|
+
if (events !== undefined)
|
|
816
|
+
entry.events = events;
|
|
817
|
+
if (extensions !== undefined)
|
|
818
|
+
entry.extensions = extensions;
|
|
819
|
+
const existing = entries.findIndex((candidate) => candidate.path === resolved);
|
|
820
|
+
if (existing >= 0)
|
|
821
|
+
entries[existing] = { ...entries[existing], ...entry };
|
|
822
|
+
else
|
|
823
|
+
entries.push(entry);
|
|
824
|
+
setWatchPaths(loaded.config, entries);
|
|
825
|
+
saveDaemonConfig(loaded.path, loaded.config);
|
|
826
|
+
return {
|
|
827
|
+
exitCode: EXIT_CODES.OK,
|
|
828
|
+
message: `${existing >= 0 ? 'Updated' : 'Registered'} watch root '${resolved}' in ${loaded.path}. Watch events are reconciliation hints; cron remains the scheduling authority.`,
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
async function executeWatchDisable(c, rest) {
|
|
832
|
+
const unknown = checkUnknownFlags(rest, 'watch disable');
|
|
833
|
+
if (unknown)
|
|
834
|
+
return unknown;
|
|
835
|
+
let dir;
|
|
836
|
+
try {
|
|
837
|
+
dir = flagValue(rest, '--path');
|
|
838
|
+
}
|
|
839
|
+
catch (error) {
|
|
840
|
+
if (error instanceof MissingFlagValueError) {
|
|
841
|
+
return usageError(`aiwg housekeeper: ${error.message}.`, 'Pass --path <dir>.');
|
|
842
|
+
}
|
|
843
|
+
throw error;
|
|
844
|
+
}
|
|
845
|
+
const loaded = loadDaemonConfig(c.cwd);
|
|
846
|
+
const entries = getWatchPaths(loaded.config);
|
|
847
|
+
if (dir === undefined) {
|
|
848
|
+
if (entries.length === 0) {
|
|
849
|
+
return usageError('aiwg housekeeper: no watch roots configured.', 'Nothing to disable.');
|
|
850
|
+
}
|
|
851
|
+
if (!c.interactive) {
|
|
852
|
+
return usageError('aiwg housekeeper: --path requires a value.', `Pass --path <dir>; known roots: ${entries.map((entry) => entry.path).join(', ')}.`);
|
|
853
|
+
}
|
|
854
|
+
const rl = createPromptInterface();
|
|
855
|
+
try {
|
|
856
|
+
dir = await listSelect(rl, ' Disable watch root: ', entries.map((entry) => ({ label: entry.path, value: entry.path })), entries[0].path, c.ctx.signal);
|
|
857
|
+
}
|
|
858
|
+
finally {
|
|
859
|
+
rl.close();
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
const remaining = entries.filter((entry) => entry.path !== dir);
|
|
863
|
+
if (remaining.length === entries.length) {
|
|
864
|
+
return usageError(`aiwg housekeeper: unknown watch root '${dir}'.`, `Known roots: ${entries.map((entry) => entry.path).join(', ') || '(none)'}.`);
|
|
865
|
+
}
|
|
866
|
+
setWatchPaths(loaded.config, remaining);
|
|
867
|
+
saveDaemonConfig(loaded.path, loaded.config);
|
|
868
|
+
return { exitCode: EXIT_CODES.OK, message: `Disabled watch root '${dir}' (removed from ${loaded.path})` };
|
|
869
|
+
}
|
|
870
|
+
async function executeWatch(c, rest) {
|
|
871
|
+
const [verb = 'list', ...verbArgs] = rest;
|
|
872
|
+
if (verb === 'list') {
|
|
873
|
+
const unknown = checkUnknownFlags(verbArgs, 'watch list');
|
|
874
|
+
if (unknown)
|
|
875
|
+
return unknown;
|
|
876
|
+
const loaded = loadDaemonConfig(c.cwd);
|
|
877
|
+
const entries = getWatchPaths(loaded.config);
|
|
878
|
+
if (hasSwitch(verbArgs, '--json')) {
|
|
879
|
+
return { exitCode: EXIT_CODES.OK, message: JSON.stringify({ paths: entries }, null, 2), rawOutput: true };
|
|
880
|
+
}
|
|
881
|
+
return { exitCode: EXIT_CODES.OK, message: formatWatchHuman(entries) };
|
|
882
|
+
}
|
|
883
|
+
if (verb === 'register')
|
|
884
|
+
return executeWatchRegister(c, verbArgs);
|
|
885
|
+
if (verb === 'disable')
|
|
886
|
+
return executeWatchDisable(c, verbArgs);
|
|
887
|
+
return usageError(`aiwg housekeeper: unknown watch subcommand '${verb}'.`, 'Use `watch list|register|disable`.');
|
|
888
|
+
}
|
|
889
|
+
export function planInstall(options) {
|
|
890
|
+
const ctx = { aiwgBin: options.aiwgBin, cwd: options.cwd };
|
|
891
|
+
if (options.backend === 'launchd') {
|
|
892
|
+
return {
|
|
893
|
+
backend: 'launchd',
|
|
894
|
+
dir: options.launchdDir ?? launchdAgentDir(),
|
|
895
|
+
files: [{ name: `${HOUSEKEEPER_LAUNCHD_LABEL}.plist`, content: renderLaunchdPlist(ctx) }],
|
|
896
|
+
enableHint: `launchctl load ~/Library/LaunchAgents/${HOUSEKEEPER_LAUNCHD_LABEL}.plist (best-effort macOS support; not CI-tested)`,
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
const files = [{ name: HOUSEKEEPER_SERVICE_NAME, content: renderSystemdService(ctx) }];
|
|
900
|
+
if (options.withTimer) {
|
|
901
|
+
files.push({ name: HOUSEKEEPER_TIMER_NAME, content: renderSystemdTimer({ calendar: options.calendar }) });
|
|
902
|
+
}
|
|
903
|
+
const timerHint = options.withTimer
|
|
904
|
+
? `systemctl --user daemon-reload && systemctl --user enable --now ${HOUSEKEEPER_TIMER_NAME}`
|
|
905
|
+
: `systemctl --user daemon-reload (add --with-timer to also install ${HOUSEKEEPER_TIMER_NAME} calling run-once)`;
|
|
906
|
+
return {
|
|
907
|
+
backend: 'systemd-user',
|
|
908
|
+
dir: options.systemdDir ?? systemdUserDir(),
|
|
909
|
+
files,
|
|
910
|
+
enableHint: timerHint,
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
export function unitTargetDir(backend, overrides = {}) {
|
|
914
|
+
if (backend === 'launchd') {
|
|
915
|
+
return overrides.launchdDir
|
|
916
|
+
?? process.env['AIWG_HOUSEKEEPER_LAUNCHD_DIR']
|
|
917
|
+
?? launchdAgentDir();
|
|
918
|
+
}
|
|
919
|
+
return overrides.systemdDir
|
|
920
|
+
?? process.env['AIWG_HOUSEKEEPER_SYSTEMD_DIR']
|
|
921
|
+
?? systemdUserDir();
|
|
922
|
+
}
|
|
923
|
+
async function selectBackend(c, platform) {
|
|
924
|
+
const offered = installBackends(platform);
|
|
925
|
+
if (offered.length === 0) {
|
|
926
|
+
return { error: { exitCode: EXIT_CODES.GENERAL, message: runUnsupportedMessage() } };
|
|
927
|
+
}
|
|
928
|
+
if (!c.interactive) {
|
|
929
|
+
return {
|
|
930
|
+
error: usageError('aiwg housekeeper: install requires an explicit backend flag.', `Pass ${offered.includes('systemd-user') ? '--systemd-user' : '--launchd'}.`),
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
const rl = createPromptInterface();
|
|
934
|
+
try {
|
|
935
|
+
return await listSelect(rl, ' Install backend: ', offered.map((backend) => ({
|
|
936
|
+
label: backend === 'systemd-user' ? 'systemd user units (Linux)' : 'launchd plist (macOS, best-effort)',
|
|
937
|
+
value: backend,
|
|
938
|
+
})), offered[0], c.ctx.signal);
|
|
939
|
+
}
|
|
940
|
+
finally {
|
|
941
|
+
rl.close();
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
async function executeInstall(c, rest) {
|
|
945
|
+
const unknown = checkUnknownFlags(rest, 'install');
|
|
946
|
+
if (unknown)
|
|
947
|
+
return unknown;
|
|
948
|
+
const platform = process.platform;
|
|
949
|
+
if (normalizePlatform(platform) === 'windows') {
|
|
950
|
+
return { exitCode: EXIT_CODES.GENERAL, message: runUnsupportedMessage() };
|
|
951
|
+
}
|
|
952
|
+
const wantSystemd = hasSwitch(rest, '--systemd-user');
|
|
953
|
+
const wantLaunchd = hasSwitch(rest, '--launchd');
|
|
954
|
+
if (wantSystemd && wantLaunchd) {
|
|
955
|
+
return usageError('aiwg housekeeper: pass only one of --systemd-user or --launchd.', 'Run `aiwg housekeeper --help` for usage.');
|
|
956
|
+
}
|
|
957
|
+
let backend;
|
|
958
|
+
if (wantSystemd)
|
|
959
|
+
backend = 'systemd-user';
|
|
960
|
+
else if (wantLaunchd)
|
|
961
|
+
backend = 'launchd';
|
|
962
|
+
else {
|
|
963
|
+
const picked = await selectBackend(c, platform);
|
|
964
|
+
if (typeof picked !== 'string')
|
|
965
|
+
return picked.error;
|
|
966
|
+
backend = picked;
|
|
967
|
+
}
|
|
968
|
+
if (backend === 'systemd-user' && normalizePlatform(platform) !== 'linux') {
|
|
969
|
+
return {
|
|
970
|
+
exitCode: EXIT_CODES.USAGE,
|
|
971
|
+
message: 'aiwg housekeeper: --systemd-user is only supported on Linux. On macOS use --launchd (best-effort).',
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
const dryRun = hasSwitch(rest, '--dry-run');
|
|
975
|
+
const withTimer = hasSwitch(rest, '--with-timer');
|
|
976
|
+
let calendar;
|
|
977
|
+
try {
|
|
978
|
+
calendar = flagValue(rest, '--calendar');
|
|
979
|
+
}
|
|
980
|
+
catch (error) {
|
|
981
|
+
if (error instanceof MissingFlagValueError) {
|
|
982
|
+
return usageError(`aiwg housekeeper: ${error.message}.`, 'Example: --calendar hourly.');
|
|
983
|
+
}
|
|
984
|
+
throw error;
|
|
985
|
+
}
|
|
986
|
+
const aiwgBin = process.env['AIWG_HOUSEKEEPER_BIN'] ?? defaultAiwgBin(c.frameworkRoot);
|
|
987
|
+
const plan = planInstall({
|
|
988
|
+
backend, aiwgBin, cwd: c.cwd, withTimer, calendar,
|
|
989
|
+
systemdDir: unitTargetDir('systemd-user'),
|
|
990
|
+
launchdDir: unitTargetDir('launchd'),
|
|
991
|
+
});
|
|
992
|
+
if (dryRun) {
|
|
993
|
+
const parts = [`# install --dry-run: ${plan.files.length} file(s) under ${plan.dir} (nothing written)`];
|
|
994
|
+
for (const file of plan.files) {
|
|
995
|
+
parts.push(`--- ${file.name} ---`, file.content.trimEnd());
|
|
996
|
+
}
|
|
997
|
+
parts.push(`Enable (explicit opt-in, never automatic): ${plan.enableHint}`);
|
|
998
|
+
return { exitCode: EXIT_CODES.OK, message: parts.join('\n'), rawOutput: true };
|
|
999
|
+
}
|
|
1000
|
+
fs.mkdirSync(plan.dir, { recursive: true });
|
|
1001
|
+
const written = [];
|
|
1002
|
+
for (const file of plan.files) {
|
|
1003
|
+
const target = path.join(plan.dir, file.name);
|
|
1004
|
+
const tmp = `${target}.tmp.${process.pid}`;
|
|
1005
|
+
fs.writeFileSync(tmp, file.content);
|
|
1006
|
+
fs.renameSync(tmp, target);
|
|
1007
|
+
written.push(target);
|
|
1008
|
+
}
|
|
1009
|
+
return {
|
|
1010
|
+
exitCode: EXIT_CODES.OK,
|
|
1011
|
+
message: [
|
|
1012
|
+
`Wrote ${written.length} unit file(s):`,
|
|
1013
|
+
...written.map((file) => ` ${file}`),
|
|
1014
|
+
'Autostart is NOT enabled. To enable it explicitly:',
|
|
1015
|
+
` ${plan.enableHint}`,
|
|
1016
|
+
].join('\n'),
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
function knownUnitFiles(dir, names) {
|
|
1020
|
+
return names.map((name) => path.join(dir, name)).filter((file) => fs.existsSync(file));
|
|
1021
|
+
}
|
|
1022
|
+
async function executeUninstall(_c, rest) {
|
|
1023
|
+
const unknown = checkUnknownFlags(rest, 'uninstall');
|
|
1024
|
+
if (unknown)
|
|
1025
|
+
return unknown;
|
|
1026
|
+
const dryRun = hasSwitch(rest, '--dry-run');
|
|
1027
|
+
const candidates = [
|
|
1028
|
+
...knownUnitFiles(unitTargetDir('systemd-user'), [HOUSEKEEPER_SERVICE_NAME, HOUSEKEEPER_TIMER_NAME]),
|
|
1029
|
+
...knownUnitFiles(unitTargetDir('launchd'), [`${HOUSEKEEPER_LAUNCHD_LABEL}.plist`]),
|
|
1030
|
+
];
|
|
1031
|
+
if (dryRun) {
|
|
1032
|
+
return {
|
|
1033
|
+
exitCode: EXIT_CODES.OK,
|
|
1034
|
+
message: candidates.length
|
|
1035
|
+
? `uninstall --dry-run: would remove ${candidates.length} file(s) (nothing written):\n${candidates.map((file) => ` ${file}`).join('\n')}`
|
|
1036
|
+
: 'uninstall --dry-run: no housekeeper units installed (nothing to do)',
|
|
1037
|
+
rawOutput: true,
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
const removed = [];
|
|
1041
|
+
for (const file of candidates) {
|
|
1042
|
+
try {
|
|
1043
|
+
fs.unlinkSync(file);
|
|
1044
|
+
removed.push(file);
|
|
1045
|
+
}
|
|
1046
|
+
catch (error) {
|
|
1047
|
+
if (error?.code !== 'ENOENT')
|
|
1048
|
+
throw error;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
return {
|
|
1052
|
+
exitCode: EXIT_CODES.OK,
|
|
1053
|
+
message: removed.length
|
|
1054
|
+
? `Removed ${removed.length} unit file(s):\n${removed.map((file) => ` ${file}`).join('\n')}`
|
|
1055
|
+
: 'No housekeeper units installed (nothing to do)',
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
// ── dispatch ──────────────────────────────────────────────────────
|
|
1059
|
+
async function dispatch(c) {
|
|
1060
|
+
const [subcommand, ...rest] = c.args;
|
|
1061
|
+
switch (subcommand) {
|
|
1062
|
+
case 'start': return executeStart({ ...c, args: rest });
|
|
1063
|
+
case 'stop': return executeStop({ ...c, args: rest });
|
|
1064
|
+
case 'status': return executeStatus({ ...c, args: rest });
|
|
1065
|
+
case 'run-once': return executeRunOnce({ ...c, args: rest });
|
|
1066
|
+
case 'job': return executeJob(c, rest);
|
|
1067
|
+
case 'watch': return executeWatch(c, rest);
|
|
1068
|
+
case 'install': return executeInstall(c, rest);
|
|
1069
|
+
case 'uninstall': return executeUninstall(c, rest);
|
|
1070
|
+
case undefined:
|
|
1071
|
+
return usageError('aiwg housekeeper: missing subcommand.', 'Use one of: start|stop|status|run-once|job|watch|install|uninstall.');
|
|
1072
|
+
default:
|
|
1073
|
+
return usageError(`aiwg housekeeper: unknown subcommand '${subcommand}'.`, 'Use one of: start|stop|status|run-once|job|watch|install|uninstall.');
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
async function buildCtx(ctx) {
|
|
1077
|
+
const frameworkRoot = await getFrameworkRoot();
|
|
1078
|
+
const nonInteractive = hasSwitch(ctx.args, '--non-interactive');
|
|
1079
|
+
return {
|
|
1080
|
+
ctx,
|
|
1081
|
+
args: ctx.args,
|
|
1082
|
+
cwd: ctx.cwd || process.cwd(),
|
|
1083
|
+
frameworkRoot,
|
|
1084
|
+
nonInteractive,
|
|
1085
|
+
interactive: isInteractive(nonInteractive),
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1088
|
+
export const housekeeperHandler = {
|
|
1089
|
+
id: 'housekeeper',
|
|
1090
|
+
name: 'Housekeeper',
|
|
1091
|
+
description: 'Deterministic maintenance daemon: start/stop/status/run-once, jobs, watch roots, autostart units',
|
|
1092
|
+
category: 'daemon',
|
|
1093
|
+
aliases: [],
|
|
1094
|
+
async help() {
|
|
1095
|
+
return { exitCode: 0, message: housekeeperHelpText() };
|
|
1096
|
+
},
|
|
1097
|
+
async execute(ctx) {
|
|
1098
|
+
try {
|
|
1099
|
+
return await dispatch(await buildCtx(ctx));
|
|
1100
|
+
}
|
|
1101
|
+
catch (error) {
|
|
1102
|
+
if (error instanceof MissingFlagValueError) {
|
|
1103
|
+
return usageError(`aiwg housekeeper: ${error.message}.`, 'Run `aiwg housekeeper --help` for usage.');
|
|
1104
|
+
}
|
|
1105
|
+
return handlerResultFromError(error);
|
|
1106
|
+
}
|
|
1107
|
+
},
|
|
1108
|
+
};
|
|
1109
|
+
export const housekeeperHandlers = [housekeeperHandler];
|
|
1110
|
+
//# sourceMappingURL=housekeeper.js.map
|