Skip to content

Commit 819be4a

Browse files
committed
fix(profile): detect stale publish watcher
1 parent 3cc6b8e commit 819be4a

5 files changed

Lines changed: 68 additions & 9 deletions

File tree

apps/axctl/src/cli/install.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
parseDaemonCommand,
77
resolveDaemonHostPort,
88
staleRunningIngestRuns,
9+
watcherProfilePublishDoctorCheck,
910
type DaemonStatus,
1011
type DoctorReport,
1112
} from "./install.ts";
@@ -143,6 +144,30 @@ describe("cli install operations", () => {
143144
ok: true,
144145
});
145146
});
147+
148+
test("doctor flags watcher profile publish freshness drift", () => {
149+
const check = watcherProfilePublishDoctorCheck(
150+
"<string>/bin/ax profile publish --if-stale=6 >>watcher.log 2&gt;&amp;1 || true</string>",
151+
);
152+
153+
expect(check).toEqual({
154+
name: "watcher-profile-publish",
155+
ok: false,
156+
detail: "profile publish uses --if-stale=6; expected --if-stale=2; run 'axctl install' to refresh the watcher plist",
157+
});
158+
});
159+
160+
test("doctor accepts the current watcher profile publish freshness gate", () => {
161+
const check = watcherProfilePublishDoctorCheck(
162+
"<string>/bin/ax profile publish --if-stale=2 >>watcher.log 2&gt;&amp;1 || true</string>",
163+
);
164+
165+
expect(check).toEqual({
166+
name: "watcher-profile-publish",
167+
ok: true,
168+
detail: "profile publish freshness gate: 2h",
169+
});
170+
});
146171
});
147172

148173
describe("doctor stale ingest_run detection", () => {

apps/axctl/src/cli/install.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ const DB_LABEL = "com.necmttn.ax-db";
6060
const WATCH_LABEL = "com.necmttn.ax-watch";
6161
const DERIVE_LABEL = "com.necmttn.ax-derive-daily";
6262
const QUOTA_REFRESH_LABEL = "com.necmttn.ax-quota-refresh";
63+
export const PROFILE_PUBLISH_IF_STALE_HOURS = 2;
6364
const DB_PLIST = posixPath.join(LAUNCH_AGENTS_DIR, `${DB_LABEL}.plist`);
6465
const WATCH_PLIST = posixPath.join(LAUNCH_AGENTS_DIR, `${WATCH_LABEL}.plist`);
6566
const DERIVE_PLIST = posixPath.join(LAUNCH_AGENTS_DIR, `${DERIVE_LABEL}.plist`);
@@ -176,7 +177,7 @@ const watchPlist = (binPath: string): string => `<?xml version="1.0" encoding="U
176177
<array>
177178
<string>/bin/bash</string>
178179
<string>-lc</string>
179-
<string>${binPath} ingest --since=1 >>${LOG_DIR}/watcher.log 2>&amp;1 &amp;&amp; ${binPath} derive-signals --since=1 >>${LOG_DIR}/watcher.log 2>&amp;1; ${binPath} profile publish --if-stale=2 >>${LOG_DIR}/watcher.log 2>&amp;1 || true</string>
180+
<string>${binPath} ingest --since=1 >>${LOG_DIR}/watcher.log 2>&amp;1 &amp;&amp; ${binPath} derive-signals --since=1 >>${LOG_DIR}/watcher.log 2>&amp;1; ${binPath} profile publish --if-stale=${PROFILE_PUBLISH_IF_STALE_HOURS} >>${LOG_DIR}/watcher.log 2>&amp;1 || true</string>
180181
</array>
181182
<key>WatchPaths</key>
182183
<array>
@@ -200,6 +201,33 @@ const watchPlist = (binPath: string): string => `<?xml version="1.0" encoding="U
200201
</plist>
201202
`;
202203

204+
export function watcherProfilePublishDoctorCheck(plistText: string): DoctorCheck {
205+
const expected = PROFILE_PUBLISH_IF_STALE_HOURS;
206+
const match = /profile publish --if-stale=(\d+)/.exec(plistText);
207+
if (!match) {
208+
return {
209+
name: "watcher-profile-publish",
210+
ok: false,
211+
detail: `watcher plist is missing profile publish --if-stale=${expected}; run 'axctl install' to refresh the watcher plist`,
212+
};
213+
}
214+
215+
const actual = Number.parseInt(match[1] ?? "", 10);
216+
if (actual === expected) {
217+
return {
218+
name: "watcher-profile-publish",
219+
ok: true,
220+
detail: `profile publish freshness gate: ${expected}h`,
221+
};
222+
}
223+
224+
return {
225+
name: "watcher-profile-publish",
226+
ok: false,
227+
detail: `profile publish uses --if-stale=${actual}; expected --if-stale=${expected}; run 'axctl install' to refresh the watcher plist`,
228+
};
229+
}
230+
203231
// Daily full ETL: runs once a day at 04:00 local time. Full ingest (no
204232
// --since) repulls every transcript file mtime cutoff = 0, then derives all
205233
// signals. Idempotent thanks to UPSERTs; safe to overlap with the watcher.
@@ -895,6 +923,9 @@ export function collectDoctorReport(): Effect.Effect<
895923
const runtimeStateExists = yield* fs
896924
.exists(daemon.endpoint.runtimeStatePath)
897925
.pipe(orAbsent(false));
926+
const watcherPlistText = yield* fs
927+
.readFileString(WATCH_PLIST)
928+
.pipe(orAbsent<string | null>(null));
898929
const dbReachable = daemon.dbListening && daemon.endpoint.conflict === null;
899930
const missingBuckets = dbReachable
900931
? yield* Effect.promise(() => probeMissingBuckets(daemon.endpoint))
@@ -953,6 +984,9 @@ export function collectDoctorReport(): Effect.Effect<
953984
detail: `${agent.loaded ? "loaded" : "not loaded"}; plist=${agent.plistExists ? "present" : "absent"}`,
954985
})),
955986
];
987+
if (watcherPlistText !== null) {
988+
checks.push(watcherProfilePublishDoctorCheck(watcherPlistText));
989+
}
956990
if (missingBuckets !== null) {
957991
checks.push({
958992
name: "db-buckets",

docs/superpowers/plans/2026-06-12-profile-publish.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
55
**Goal:** `ax profile publish` pushes the ProfileV1 artifact to a user-owned public gist (create once, PATCH in place), registers the user via fork + PR into `Necmttn/ax` (`community/users/<login>.json`), and the watcher keeps the gist fresh; `ax profile unpublish` reverses it.
66

7-
**Architecture:** A `GitHubEnv` Effect service (mirrors hooks-sdk `GitEnv`: Context.Service + Live/Test layers) wraps every `gh api` call so all publish logic is layer-testable without network. Local consent + gist state lives in `~/.ax/profile-publish.json` (atomic-write). The publish pipeline: render (Plan 1 `buildProfile`) → first-run consent (print exact JSON, y/N) → gist create/patch → one-time registration (fork → remote commit via git-data API, no local clone → PR). Watcher plist gains a debounced `profile publish --if-stale=6h` step that silently no-ops until the user has consented once.
7+
**Architecture:** A `GitHubEnv` Effect service (mirrors hooks-sdk `GitEnv`: Context.Service + Live/Test layers) wraps every `gh api` call so all publish logic is layer-testable without network. Local consent + gist state lives in `~/.ax/profile-publish.json` (atomic-write). The publish pipeline: render (Plan 1 `buildProfile`) → first-run consent (print exact JSON, y/N) → gist create/patch → one-time registration (fork → remote commit via git-data API, no local clone → PR). Watcher plist gains a debounced `profile publish --if-stale=2h` step that silently no-ops until the user has consented once.
88

99
**Tech Stack:** bun, effect@beta v4, `gh` CLI (auth + API transport), `@ax/lib/atomic-write`, bun:test with layer mocks.
1010

@@ -810,7 +810,7 @@ Expected: all pass (no new RUNTIME entries needed - `profile` family already reg
810810
- [ ] **Step 3: Live smoke (careful - real GitHub)**
811811

812812
```bash
813-
bun apps/axctl/src/cli/index.ts profile publish --if-stale=6 # no state -> silent no-op, exit 0
813+
bun apps/axctl/src/cli/index.ts profile publish --if-stale=2 # no state -> silent no-op, exit 0
814814
bun apps/axctl/src/cli/index.ts profile unpublish # "not published" message
815815
```
816816
Do NOT run a real first publish in CI/implementation - that creates a real gist + PR. The consent prompt path is verified by the y/N abort: run `echo n | bun apps/axctl/src/cli/index.ts profile publish` and expect "Aborted".
@@ -840,7 +840,7 @@ cd __AX_DIR__ && bun src/cli/index.ts ingest --since=1 >>__LOG_DIR__/watcher.log
840840
to:
841841

842842
```
843-
cd __AX_DIR__ && bun src/cli/index.ts ingest --since=1 >>__LOG_DIR__/watcher.log 2>&1; bun src/cli/index.ts profile publish --if-stale=6 >>__LOG_DIR__/watcher.log 2>&1 || true
843+
cd __AX_DIR__ && bun src/cli/index.ts ingest --since=1 >>__LOG_DIR__/watcher.log 2>&1; bun src/cli/index.ts profile publish --if-stale=2 >>__LOG_DIR__/watcher.log 2>&1 || true
844844
```
845845

846846
(`;` not `&&` - a failed ingest must not block the freshness check; `|| true` keeps launchd happy. The publish step is a silent no-op until the user has consented once - no state file, no publish, no prompt.)
@@ -856,7 +856,7 @@ Expected: `OK`.
856856

857857
```bash
858858
git add scripts/com.necmttn.ax-watch.plist
859-
git commit -m "feat(watcher): keep published profile fresh (publish --if-stale=6)"
859+
git commit -m "feat(watcher): keep published profile fresh (publish --if-stale=2)"
860860
```
861861

862862
---
@@ -885,7 +885,7 @@ profile (ProfileV1: stats + rig + taste patterns) from the graph.
885885
publish to a public gist (create once, PATCH in place). First run: consent
886886
prompt showing the exact JSON, then fork + community/users/<login>.json
887887
registration PR into Necmttn/ax (git-data API, no local clone). Watcher runs
888-
`--if-stale=6` after ingest - silent no-op until first consent.
888+
`--if-stale=2` after ingest - silent no-op until first consent.
889889
`ax profile unpublish` - delete gist + local state.
890890
State: `~/.ax/profile-publish.json`. Spec:
891891
docs/superpowers/specs/2026-06-12-ax-profiles-design.md.

docs/superpowers/specs/2026-06-12-ax-profiles-design.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ Identity proof: CI requires PR author == `github` field == filename.
175175
create gist; `gh repo fork Necmttn/ax`; commit
176176
`community/users/<login>.json` via GitHub API (blob/tree/commit - no
177177
local clone, zero disk); open registration PR.
178-
- Subsequent runs: PATCH gist in place. `--if-stale=6h` reads remote
178+
- Subsequent runs: PATCH gist in place. `--if-stale=2h` reads remote
179179
`generated_at` and no-ops when fresh (cheap; used by automation).
180180
- `ax profile show [--json]` - local preview, no publish.
181181
- `ax profile unpublish` - delete gist; open PR removing registration file.
@@ -265,7 +265,7 @@ non-goal.
265265
## 5. Automation
266266

267267
- **Client:** watcher (`com.necmttn.ax-watch`) post-ingest hook runs
268-
`ax profile publish --if-stale=6h` after a successful ingest - profile
268+
`ax profile publish --if-stale=2h` after a successful ingest - profile
269269
refreshes whenever the user codes. Debounce lives inside the command.
270270
- **Fallback:** `ax install` registers a daily LaunchAgent timer for machines
271271
where the watcher misses. Publish remains explicit opt-in (interactive

scripts/com.necmttn.ax-watch.plist

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
<array>
99
<string>/bin/bash</string>
1010
<string>-lc</string>
11-
<string>cd __AX_DIR__ &amp;&amp; bun src/cli/index.ts ingest --since=1 &gt;&gt;__LOG_DIR__/watcher.log 2&gt;&amp;1; bun src/cli/index.ts profile publish --if-stale=6 &gt;&gt;__LOG_DIR__/watcher.log 2&gt;&amp;1 || true</string>
11+
<string>cd __AX_DIR__ &amp;&amp; bun src/cli/index.ts ingest --since=1 &gt;&gt;__LOG_DIR__/watcher.log 2&gt;&amp;1; bun src/cli/index.ts profile publish --if-stale=2 &gt;&gt;__LOG_DIR__/watcher.log 2&gt;&amp;1 || true</string>
1212
</array>
1313
<key>WatchPaths</key>
1414
<array>

0 commit comments

Comments
 (0)