@intutic/sync-daemon 2.0.0 → 2.2.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.
Files changed (206) hide show
  1. package/dist/agentReporter.d.ts +5 -12
  2. package/dist/agentReporter.d.ts.map +1 -1
  3. package/dist/agentReporter.js +25 -4
  4. package/dist/agentReporter.js.map +1 -1
  5. package/dist/configReader.d.ts +44 -22
  6. package/dist/configReader.d.ts.map +1 -1
  7. package/dist/configReader.js +70 -44
  8. package/dist/configReader.js.map +1 -1
  9. package/dist/configWriter.d.ts +29 -41
  10. package/dist/configWriter.d.ts.map +1 -1
  11. package/dist/configWriter.js +28 -625
  12. package/dist/configWriter.js.map +1 -1
  13. package/dist/disconnect/harnesses.d.ts +31 -0
  14. package/dist/disconnect/harnesses.d.ts.map +1 -0
  15. package/dist/disconnect/harnesses.js +728 -0
  16. package/dist/disconnect/harnesses.js.map +1 -0
  17. package/dist/disconnect/index.d.ts +25 -0
  18. package/dist/disconnect/index.d.ts.map +1 -0
  19. package/dist/disconnect/index.js +82 -0
  20. package/dist/disconnect/index.js.map +1 -0
  21. package/dist/disconnect/mcp.d.ts +60 -0
  22. package/dist/disconnect/mcp.d.ts.map +1 -0
  23. package/dist/disconnect/mcp.js +283 -0
  24. package/dist/disconnect/mcp.js.map +1 -0
  25. package/dist/disconnect/originals.d.ts +88 -0
  26. package/dist/disconnect/originals.d.ts.map +1 -0
  27. package/dist/disconnect/originals.js +257 -0
  28. package/dist/disconnect/originals.js.map +1 -0
  29. package/dist/disconnect/plan.d.ts +175 -0
  30. package/dist/disconnect/plan.d.ts.map +1 -0
  31. package/dist/disconnect/plan.js +580 -0
  32. package/dist/disconnect/plan.js.map +1 -0
  33. package/dist/disconnect/recognise.d.ts +43 -0
  34. package/dist/disconnect/recognise.d.ts.map +1 -0
  35. package/dist/disconnect/recognise.js +98 -0
  36. package/dist/disconnect/recognise.js.map +1 -0
  37. package/dist/disconnect/tomlLines.d.ts +39 -0
  38. package/dist/disconnect/tomlLines.d.ts.map +1 -0
  39. package/dist/disconnect/tomlLines.js +105 -0
  40. package/dist/disconnect/tomlLines.js.map +1 -0
  41. package/dist/harness/aiderConfigMerger.d.ts +42 -5
  42. package/dist/harness/aiderConfigMerger.d.ts.map +1 -1
  43. package/dist/harness/aiderConfigMerger.js +113 -86
  44. package/dist/harness/aiderConfigMerger.js.map +1 -1
  45. package/dist/harness/antigravityHooks.d.ts +13 -8
  46. package/dist/harness/antigravityHooks.d.ts.map +1 -1
  47. package/dist/harness/antigravityHooks.js +53 -39
  48. package/dist/harness/antigravityHooks.js.map +1 -1
  49. package/dist/harness/claudeCodeHooks.d.ts +1 -2
  50. package/dist/harness/claudeCodeHooks.d.ts.map +1 -1
  51. package/dist/harness/claudeCodeHooks.js +61 -34
  52. package/dist/harness/claudeCodeHooks.js.map +1 -1
  53. package/dist/harness/claudeDesktopHooks.d.ts +3 -0
  54. package/dist/harness/claudeDesktopHooks.d.ts.map +1 -1
  55. package/dist/harness/claudeDesktopHooks.js +3 -1
  56. package/dist/harness/claudeDesktopHooks.js.map +1 -1
  57. package/dist/harness/claudeProjectApproval.d.ts +53 -0
  58. package/dist/harness/claudeProjectApproval.d.ts.map +1 -0
  59. package/dist/harness/claudeProjectApproval.js +159 -0
  60. package/dist/harness/claudeProjectApproval.js.map +1 -0
  61. package/dist/harness/clineHooks.d.ts +39 -9
  62. package/dist/harness/clineHooks.d.ts.map +1 -1
  63. package/dist/harness/clineHooks.js +142 -60
  64. package/dist/harness/clineHooks.js.map +1 -1
  65. package/dist/harness/codexConfigMerger.d.ts +38 -0
  66. package/dist/harness/codexConfigMerger.d.ts.map +1 -0
  67. package/dist/harness/codexConfigMerger.js +105 -0
  68. package/dist/harness/codexConfigMerger.js.map +1 -0
  69. package/dist/harness/codexHooks.d.ts +4 -2
  70. package/dist/harness/codexHooks.d.ts.map +1 -1
  71. package/dist/harness/codexHooks.js +14 -11
  72. package/dist/harness/codexHooks.js.map +1 -1
  73. package/dist/harness/continueConfigMerger.d.ts +46 -0
  74. package/dist/harness/continueConfigMerger.d.ts.map +1 -0
  75. package/dist/harness/continueConfigMerger.js +94 -0
  76. package/dist/harness/continueConfigMerger.js.map +1 -0
  77. package/dist/harness/continueHooks.d.ts +15 -25
  78. package/dist/harness/continueHooks.d.ts.map +1 -1
  79. package/dist/harness/continueHooks.js +25 -119
  80. package/dist/harness/continueHooks.js.map +1 -1
  81. package/dist/harness/cursorHooks.d.ts +33 -16
  82. package/dist/harness/cursorHooks.d.ts.map +1 -1
  83. package/dist/harness/cursorHooks.js +69 -25
  84. package/dist/harness/cursorHooks.js.map +1 -1
  85. package/dist/harness/dshHooks.d.ts +59 -52
  86. package/dist/harness/dshHooks.d.ts.map +1 -1
  87. package/dist/harness/dshHooks.js +152 -147
  88. package/dist/harness/dshHooks.js.map +1 -1
  89. package/dist/harness/gateBody.d.ts +11 -2
  90. package/dist/harness/gateBody.d.ts.map +1 -1
  91. package/dist/harness/gateBody.js +33 -6
  92. package/dist/harness/gateBody.js.map +1 -1
  93. package/dist/harness/gateKind.d.ts.map +1 -1
  94. package/dist/harness/gateKind.js +1 -0
  95. package/dist/harness/gateKind.js.map +1 -1
  96. package/dist/harness/githubCopilotHooks.d.ts.map +1 -1
  97. package/dist/harness/githubCopilotHooks.js +5 -10
  98. package/dist/harness/githubCopilotHooks.js.map +1 -1
  99. package/dist/harness/gooseHooks.d.ts +15 -0
  100. package/dist/harness/gooseHooks.d.ts.map +1 -1
  101. package/dist/harness/gooseHooks.js +55 -43
  102. package/dist/harness/gooseHooks.js.map +1 -1
  103. package/dist/harness/grokHooks.d.ts.map +1 -1
  104. package/dist/harness/grokHooks.js +14 -12
  105. package/dist/harness/grokHooks.js.map +1 -1
  106. package/dist/harness/hermesHooks.d.ts +9 -0
  107. package/dist/harness/hermesHooks.d.ts.map +1 -1
  108. package/dist/harness/hermesHooks.js +39 -33
  109. package/dist/harness/hermesHooks.js.map +1 -1
  110. package/dist/harness/holdRedaction.d.ts +4 -1
  111. package/dist/harness/holdRedaction.d.ts.map +1 -1
  112. package/dist/harness/holdRedaction.js +32 -8
  113. package/dist/harness/holdRedaction.js.map +1 -1
  114. package/dist/harness/jetbrainsXmlConfig.d.ts +20 -0
  115. package/dist/harness/jetbrainsXmlConfig.d.ts.map +1 -1
  116. package/dist/harness/jetbrainsXmlConfig.js +39 -18
  117. package/dist/harness/jetbrainsXmlConfig.js.map +1 -1
  118. package/dist/harness/jsonMergeTarget.d.ts +23 -0
  119. package/dist/harness/jsonMergeTarget.d.ts.map +1 -0
  120. package/dist/harness/jsonMergeTarget.js +50 -0
  121. package/dist/harness/jsonMergeTarget.js.map +1 -0
  122. package/dist/harness/mcpAutoWrite.d.ts +64 -20
  123. package/dist/harness/mcpAutoWrite.d.ts.map +1 -1
  124. package/dist/harness/mcpAutoWrite.js +429 -85
  125. package/dist/harness/mcpAutoWrite.js.map +1 -1
  126. package/dist/harness/museHooks.d.ts.map +1 -1
  127. package/dist/harness/museHooks.js +6 -2
  128. package/dist/harness/museHooks.js.map +1 -1
  129. package/dist/harness/n8nHooks.d.ts.map +1 -1
  130. package/dist/harness/n8nHooks.js +6 -7
  131. package/dist/harness/n8nHooks.js.map +1 -1
  132. package/dist/harness/openCodeHooks.d.ts +37 -6
  133. package/dist/harness/openCodeHooks.d.ts.map +1 -1
  134. package/dist/harness/openCodeHooks.js +103 -13
  135. package/dist/harness/openCodeHooks.js.map +1 -1
  136. package/dist/harness/openWebuiHooks.d.ts.map +1 -1
  137. package/dist/harness/openWebuiHooks.js +10 -5
  138. package/dist/harness/openWebuiHooks.js.map +1 -1
  139. package/dist/harness/openclawHooks.d.ts +5 -0
  140. package/dist/harness/openclawHooks.d.ts.map +1 -1
  141. package/dist/harness/openclawHooks.js +3 -1
  142. package/dist/harness/openclawHooks.js.map +1 -1
  143. package/dist/harness/openhandsHooks.d.ts +26 -1
  144. package/dist/harness/openhandsHooks.d.ts.map +1 -1
  145. package/dist/harness/openhandsHooks.js +160 -40
  146. package/dist/harness/openhandsHooks.js.map +1 -1
  147. package/dist/harness/piHooks.d.ts +17 -0
  148. package/dist/harness/piHooks.d.ts.map +1 -1
  149. package/dist/harness/piHooks.js +31 -29
  150. package/dist/harness/piHooks.js.map +1 -1
  151. package/dist/harness/protectedPaths.d.ts +64 -6
  152. package/dist/harness/protectedPaths.d.ts.map +1 -1
  153. package/dist/harness/protectedPaths.js +161 -11
  154. package/dist/harness/protectedPaths.js.map +1 -1
  155. package/dist/harness/rooCodeHooks.d.ts.map +1 -1
  156. package/dist/harness/rooCodeHooks.js +5 -10
  157. package/dist/harness/rooCodeHooks.js.map +1 -1
  158. package/dist/harness/windsurfHooks.d.ts +21 -3
  159. package/dist/harness/windsurfHooks.d.ts.map +1 -1
  160. package/dist/harness/windsurfHooks.js +96 -44
  161. package/dist/harness/windsurfHooks.js.map +1 -1
  162. package/dist/harness/windsurfJetBrainsProxy.d.ts +10 -7
  163. package/dist/harness/windsurfJetBrainsProxy.d.ts.map +1 -1
  164. package/dist/harness/windsurfJetBrainsProxy.js +22 -7
  165. package/dist/harness/windsurfJetBrainsProxy.js.map +1 -1
  166. package/dist/index.d.ts +12 -12
  167. package/dist/index.d.ts.map +1 -1
  168. package/dist/index.js +11 -10
  169. package/dist/index.js.map +1 -1
  170. package/dist/lib/decisionsDigest.d.ts +1 -1
  171. package/dist/lib/decisionsDigest.js +1 -1
  172. package/dist/lib/policySnapshot.d.ts +37 -0
  173. package/dist/lib/policySnapshot.d.ts.map +1 -1
  174. package/dist/lib/policySnapshot.js +170 -8
  175. package/dist/lib/policySnapshot.js.map +1 -1
  176. package/dist/sessionReporter.d.ts +2 -2
  177. package/dist/sessionReporter.js +2 -2
  178. package/dist/syncCycle.d.ts +142 -0
  179. package/dist/syncCycle.d.ts.map +1 -0
  180. package/dist/syncCycle.js +315 -0
  181. package/dist/syncCycle.js.map +1 -0
  182. package/dist/trajectoryMonitor.d.ts +1 -1
  183. package/dist/trajectoryMonitor.d.ts.map +1 -1
  184. package/dist/trajectoryMonitor.js +1 -1
  185. package/dist/trajectoryMonitor.js.map +1 -1
  186. package/dist/watcher/settingsGuard.d.ts +4 -1
  187. package/dist/watcher/settingsGuard.d.ts.map +1 -1
  188. package/dist/watcher/settingsGuard.js +42 -18
  189. package/dist/watcher/settingsGuard.js.map +1 -1
  190. package/package.json +6 -6
  191. package/dist/hashReporter.d.ts +0 -33
  192. package/dist/hashReporter.d.ts.map +0 -1
  193. package/dist/hashReporter.js +0 -69
  194. package/dist/hashReporter.js.map +0 -1
  195. package/dist/integrityStore.d.ts +0 -35
  196. package/dist/integrityStore.d.ts.map +0 -1
  197. package/dist/integrityStore.js +0 -71
  198. package/dist/integrityStore.js.map +0 -1
  199. package/dist/lib/gitWorktrees.d.ts +0 -140
  200. package/dist/lib/gitWorktrees.d.ts.map +0 -1
  201. package/dist/lib/gitWorktrees.js +0 -191
  202. package/dist/lib/gitWorktrees.js.map +0 -1
  203. package/dist/syncLoop.d.ts +0 -172
  204. package/dist/syncLoop.d.ts.map +0 -1
  205. package/dist/syncLoop.js +0 -878
  206. package/dist/syncLoop.js.map +0 -1
package/dist/syncLoop.js DELETED
@@ -1,878 +0,0 @@
1
- /**
2
- * syncLoop.ts — Core bidirectional sync loop.
3
- *
4
- * The sync loop is the heartbeat of the daemon. Each iteration:
5
- * 1. Fetches config from the control plane (POST /api/v1/sync/config)
6
- * 2. If configVersion > local → writes SOP content to harness files
7
- * 3. Computes SHA-256 hashes of local config files
8
- * 4. Reports hashes + status back to the control plane
9
- * 5. Calls the onSync callback with the result
10
- * 6. Sleeps for pollIntervalMs (AbortSignal-aware)
11
- * 7. Repeats until signal.aborted
12
- *
13
- * Design: never crash on a single iteration failure. Errors are
14
- * caught per-iteration, logged, and the loop continues.
15
- *
16
- * HLD §3.14 — Real-Time State Mirroring
17
- * LLD #8 — Sync Daemon / CLI
18
- *
19
- * @module
20
- */
21
- import * as crypto from 'node:crypto';
22
- import * as path from 'node:path';
23
- import * as fs from 'node:fs';
24
- import * as os from 'node:os';
25
- import { newIso } from '@intutic/id';
26
- import { deriveEnforcementInputs } from '@intutic/shared-types';
27
- import { writeConfigFiles, HARNESS_FILES, applyConfigEdits, loadLocalSopEntries } from './configWriter.js';
28
- import { injectMcpServer } from './harness/mcpAutoWrite.js';
29
- import { discoverWorktrees } from './lib/gitWorktrees.js';
30
- import { computeFileHashes } from './hashReporter.js';
31
- import { loadIntegrity, saveIntegrity } from './integrityStore.js';
32
- import { drainHookEvents, drainReviewRequests, REVIEW_REQUESTS_LOG, parseSopConstraints, } from './harness/claudeCodeHooks.js';
33
- import { writeRuntimeEnv } from './lib/runtimeEnv.js';
34
- import { refreshPolicySnapshot } from './lib/policySnapshot.js';
35
- import { refreshApprovedBypasses } from './lib/approvedBypasses.js';
36
- import { refreshEgressPolicy } from './lib/egressPolicy.js';
37
- import { refreshDecisionsDigest } from './lib/decisionsDigest.js';
38
- import { runComplianceProbes } from './lib/complianceProbes.js';
39
- import { startWatcher } from './watcher/driftWatcher.js';
40
- import { shouldCaptureThisIteration, captureAndUpload } from './configReader.js';
41
- import { watch } from 'chokidar';
42
- // Named import, not default: this package is ESM ("type": "module") and under
43
- // Node16 resolution the default import of ioredis's CJS typings resolves to the
44
- // module namespace, which is neither constructable nor usable as a type. The
45
- // named export is the class itself. (The old `new (Redis as any)(...)` cast
46
- // silenced exactly this, at the cost of also erasing the instance type.)
47
- import { Redis } from 'ioredis';
48
- import { TrajectoryMonitor } from './trajectoryMonitor.js';
49
- import { collectAgentReport, reportAgent, fetchLocalProxyInstanceId } from './agentReporter.js';
50
- import { startHarnessSession, endAllOpenSessions } from './sessionReporter.js';
51
- /** Narrow an unknown thrown value to a printable message. */
52
- function errorMessage(err) {
53
- return err instanceof Error ? err.message : String(err);
54
- }
55
- /**
56
- * Structural guard for trace events arriving over the `trace:live:*` Valkey
57
- * channel. The payload is JSON off the wire, so it is genuinely unknown here;
58
- * `handleTraceEvent` indexes `sessionId`/`workspaceId` immediately and a
59
- * malformed publish would otherwise fault inside the buffer bookkeeping.
60
- */
61
- function isTraceEvent(value) {
62
- if (typeof value !== 'object' || value === null)
63
- return false;
64
- const e = value;
65
- return typeof e['sessionId'] === 'string' && typeof e['workspaceId'] === 'string';
66
- }
67
- /**
68
- * Turns a non-clean `scanSkillContent` result — already computed as part of
69
- * `collectAgentReport`'s `facets.skills` — into a `skill_flagged` hook event
70
- * (TD-358).
71
- *
72
- * Deliberately reuses the report the daemon already built for this cycle
73
- * rather than re-scanning: `agentReporter.ts`'s `collectSkills` runs
74
- * `scanSkillContent` on every `.agents/skills/**\/SKILL.md` as part of
75
- * building `facets.skills` already, so this only turns a result the daemon
76
- * already has into a visible signal — it does not add a second scan pass.
77
- *
78
- * Appends directly to `.intutic/events/hook-events.jsonl`, the SAME file
79
- * every emitted PreToolUse gate's `log_event`/`logEvent` writes to and the
80
- * SAME mechanism `onDriftDetected` below already uses for `config_tamper` —
81
- * not a new event pipeline. `drainHookEvents` (`claudeCodeHooks.ts`) picks
82
- * this line up on its next pass exactly like a gate-written one, because it
83
- * only ever reads the file; it does not care who appended a given line.
84
- *
85
- * Report-only, per `scanSkillContent`'s own doc comment on the unmeasured
86
- * false-positive rate against real, benign skill markdown: this NEVER
87
- * blocks, mutates, or removes a skill. `hookEvents.ts`'s
88
- * `HookEventSchema.event` enum (control plane) counts `skill_flagged`
89
- * exactly like `tool_flagged` — advisory telemetry, not an incident.
90
- *
91
- * A failure to append is caught and warned, never thrown: a governance
92
- * telemetry write must not be able to abort the sync cycle it rides in on.
93
- */
94
- export function emitSkillFlaggedEvents(opts) {
95
- const eventsLog = path.join(opts.workspaceRoot, '.intutic', 'events', 'hook-events.jsonl');
96
- for (const skill of opts.skills) {
97
- if (!skill.scanned || skill.clean)
98
- continue;
99
- const key = `${skill.source}:${skill.name}`;
100
- if (opts.alreadyEmitted.has(key))
101
- continue;
102
- opts.alreadyEmitted.add(key);
103
- try {
104
- const ts = newIso();
105
- const entry = JSON.stringify({
106
- event: 'skill_flagged',
107
- toolName: `skill:${skill.name}`,
108
- reason: `scanSkillContent found ${skill.findingsCount} finding(s) in ` +
109
- `${skill.source}/${skill.name}/SKILL.md — advisory only, see skillScan.ts`,
110
- workspaceId: opts.workspaceId,
111
- harnessType: opts.harnessType,
112
- timestamp: ts,
113
- incidentId: crypto
114
- .createHash('sha1')
115
- .update(ts + skill.name + opts.workspaceId)
116
- .digest('hex')
117
- .slice(0, 16),
118
- filePath: `${skill.source}/${skill.name}/SKILL.md`,
119
- }) + '\n';
120
- fs.appendFileSync(eventsLog, entry, { flag: 'a' });
121
- }
122
- catch (err) {
123
- console.warn('[sync-daemon] failed to write skill_flagged event (non-fatal):', err);
124
- }
125
- }
126
- }
127
- // ─── Default constants ───────────────────────────────────────────────
128
- const DEFAULT_POLL_INTERVAL_MS = 30_000;
129
- /**
130
- * Best-effort reachability probe for the control plane, modelled on
131
- * `agentReporter.fetchEgressStatus()`'s bounded-fetch-and-swallow shape.
132
- *
133
- * The trajectory monitor used to start on `VALKEY_URL || apiKey` alone —
134
- * never checking `controlPlaneUrl` at all, so an unset URL produced
135
- * `undefined/api/v1/...` on every submit attempt. This is the missing check:
136
- * a monitor that will publish its summaries to a URL that does not resolve
137
- * (or does not answer) should not start believing it has somewhere to send
138
- * them.
139
- */
140
- async function controlPlaneIsReachable(controlPlaneUrl) {
141
- if (!controlPlaneUrl)
142
- return false;
143
- try {
144
- const res = await fetch(`${controlPlaneUrl}/healthz`, { signal: AbortSignal.timeout(2000) });
145
- return res.ok;
146
- }
147
- catch {
148
- return false;
149
- }
150
- }
151
- /**
152
- * The `review_before:` tokens the snapshot compiles into hold rules: from the
153
- * synced SOPs' front matter and JSON blocks, the workspace settings, and the
154
- * local `.intutic/sops`. The same parse the Claude Code writer runs for its
155
- * pattern blacklist, so the two cannot disagree about what is held.
156
- */
157
- export async function localHoldTokensFor(workspaceRoot, sops, settings, harnesses = []) {
158
- const local = await loadLocalSopEntries(workspaceRoot, harnesses);
159
- return parseSopConstraints([...sops, ...local], settings).reviewBefore;
160
- }
161
- /**
162
- * Refresh the three gate-side caches every hook reads: the policy snapshot
163
- * (`~/.intutic/hooks/policy-snapshot.rules`), the approved review-hold
164
- * bypasses and the central egress policy. Called on EVERY sync cycle by both
165
- * loops — this one and `intutic connect`'s — never only when the config
166
- * version moved: policy changes without the version changing, and before
167
- * this helper existed `connect` refreshed once at startup and never again, so
168
- * a guardrail promoted mid-session reached a connected machine only on
169
- * restart (TD-488). None of the three throw; the return says which of them
170
- * landed.
171
- */
172
- export async function refreshGateCaches(opts) {
173
- const snapshot = (await refreshPolicySnapshot(opts)) !== null;
174
- const bypasses = (await refreshApprovedBypasses(opts)) !== null;
175
- const egress = (await refreshEgressPolicy(opts)) !== null;
176
- return { snapshot, bypasses, egress };
177
- }
178
- export async function startSyncLoop(options) {
179
- const { controlPlaneUrl, apiKey, workspaceId, workspaceRoot, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, onSync, signal, } = options;
180
- // Step -1: Start the Trajectory Monitor & Valkey Subscriber
181
- let trajectoryMonitor = null;
182
- let trajectorySubscriber = null;
183
- const valkeyUrl = process.env.VALKEY_URL ?? 'redis://127.0.0.1:6379';
184
- if ((process.env.VALKEY_URL || apiKey) && (await controlPlaneIsReachable(controlPlaneUrl))) {
185
- trajectoryMonitor = new TrajectoryMonitor({
186
- valkeyUrl,
187
- controlPlaneUrl,
188
- apiKey,
189
- windowMs: 300_000,
190
- submitIntervalMs: 60_000,
191
- });
192
- try {
193
- await trajectoryMonitor.start();
194
- trajectorySubscriber = new Redis(valkeyUrl);
195
- await trajectorySubscriber.psubscribe('trace:live:*');
196
- trajectorySubscriber.on('pmessage', (_pattern, _channel, message) => {
197
- try {
198
- const event = JSON.parse(message);
199
- if (!isTraceEvent(event)) {
200
- console.warn('[sync-daemon] Discarding malformed trajectory trace event');
201
- return;
202
- }
203
- trajectoryMonitor?.handleTraceEvent(event);
204
- }
205
- catch (err) {
206
- // Non-blocking warning
207
- console.warn('[sync-daemon] Failed to parse trajectory trace event:', err);
208
- }
209
- });
210
- console.log('[sync-daemon] Trajectory monitor & subscriber started successfully');
211
- }
212
- catch (err) {
213
- console.warn('[sync-daemon] Could not start trajectory monitor:', err);
214
- }
215
- }
216
- // Tracking latest settings for use in drift handler
217
- let latestSettings;
218
- let latestSops = [];
219
- let latestProxyUrl = '';
220
- let latestHarnesses = [];
221
- let latestGovernanceInputs = {};
222
- // Step 0: Write runtime env file (hook scripts source this for credentials)
223
- // Runs once at startup and then again on every iteration.
224
- try {
225
- // The Valkey URL rides along only when this loop is itself using one
226
- // (the trajectory monitor connected): the MCP proxies share their session
227
- // window through it (Wave 5.3, TD-437).
228
- await writeRuntimeEnv({ controlPlaneUrl, apiKey, workspaceId, valkeyUrl: trajectoryMonitor ? valkeyUrl : undefined });
229
- }
230
- catch (err) {
231
- console.warn('[sync-daemon] Could not write runtime env file (non-fatal):', err);
232
- }
233
- // Step 0b–0d: Refresh the three gate-side caches every hook reads —
234
- // policy snapshot, approved review_before bypasses, central egress policy.
235
- //
236
- // Deliberately here beside writeRuntimeEnv, and deliberately NOT inside
237
- // writeConfigFiles: that path is gated on the config version, so a snapshot
238
- // written there would go stale by construction on every cycle where the
239
- // version did not move. Policy changes without the config version changing,
240
- // and a bypass an operator just approved has to reach the gate before the
241
- // developer's very next retry. One helper, shared with `intutic connect`'s
242
- // own loop, so the two cannot drift apart again (TD-488).
243
- await refreshGateCaches({
244
- controlPlaneUrl, apiKey, workspaceId,
245
- localHoldTokens: await localHoldTokensFor(workspaceRoot, [], undefined),
246
- });
247
- // Try to sync offline traces back to PostgreSQL
248
- try {
249
- await syncOfflineTraces(controlPlaneUrl, apiKey);
250
- }
251
- catch (err) {
252
- console.warn('[sync-daemon] Could not sync offline traces (non-fatal):', err);
253
- }
254
- // Set up FSEvents-driven hook event drain (replaces 30s fixed poll for drain step).
255
- // Drains immediately when the hook-events.jsonl file changes (written by hook scripts).
256
- // Falls back to a 60s safety-net interval for NFS/Docker mounts where inotify may not fire.
257
- const hookEventsLog = `${workspaceRoot}/.intutic/events/hook-events.jsonl`;
258
- // The review-hold log, watched alongside it. Two named files rather than the
259
- // directory: a directory watch would also fire on `.rejected.jsonl`, which the
260
- // drain itself writes, and a drain that retriggers itself is a loop.
261
- const reviewHoldsLog = `${workspaceRoot}/${REVIEW_REQUESTS_LOG}`;
262
- let fsWatcher = null;
263
- let drainSafetyTimer = null;
264
- const runDrain = async () => {
265
- try {
266
- const drained = await drainHookEvents(workspaceRoot, controlPlaneUrl, apiKey);
267
- if (drained > 0) {
268
- console.log(`[sync-daemon] Drained ${drained} hook governance events to control plane`);
269
- }
270
- }
271
- catch (err) {
272
- console.warn('[sync-daemon] Hook event drain error (non-fatal):', err);
273
- }
274
- // Separate try: a failure draining events must not skip the holds. They go
275
- // to different endpoints and one being down says nothing about the other.
276
- try {
277
- const held = await drainReviewRequests(workspaceRoot, controlPlaneUrl, apiKey);
278
- if (held > 0) {
279
- console.log(`[sync-daemon] Drained ${held} review hold(s) to control plane`);
280
- }
281
- }
282
- catch (err) {
283
- console.warn('[sync-daemon] Review hold drain error (non-fatal):', err);
284
- }
285
- };
286
- try {
287
- fsWatcher = watch([hookEventsLog, reviewHoldsLog], { ignoreInitial: true, persistent: false });
288
- fsWatcher.on('change', runDrain);
289
- fsWatcher.on('add', runDrain);
290
- }
291
- catch {
292
- // chokidar unavailable — rely on the 60s safety-net only
293
- }
294
- // 60-second safety-net drain poll (catches events missed by FSEvents on NFS/Docker)
295
- drainSafetyTimer = setInterval(runDrain, 60_000);
296
- signal?.addEventListener('abort', () => {
297
- fsWatcher?.close();
298
- if (drainSafetyTimer)
299
- clearInterval(drainSafetyTimer);
300
- trajectoryMonitor?.stop();
301
- trajectorySubscriber?.disconnect();
302
- }, { once: true });
303
- // WS-5 — Drift watcher with onDriftDetected callback
304
- // Wired once after first successful sync (when we have harness list).
305
- // The callback fires a config_tamper governance event and immediately rewrites config.
306
- let driftWatcher = null;
307
- const onDriftDetected = async (changedPath) => {
308
- // 1. Immediately rewrite config (if settings allow)
309
- if (latestSettings?.bypassEnforcementTier !== 'alert-only') {
310
- try {
311
- await writeConfigFiles(workspaceRoot, latestSops, latestProxyUrl, latestHarnesses, workspaceId, latestSettings?.bypassEnforcementTier);
312
- }
313
- catch (err) {
314
- console.warn('[sync-daemon] onDriftDetected: writeConfigFiles failed (non-fatal):', err);
315
- }
316
- }
317
- // 2. Append config_tamper event to hook-events JSONL
318
- try {
319
- const tamperEntry = JSON.stringify({
320
- event: 'config_tamper',
321
- toolName: 'config_file',
322
- reason: 'Harness config file modified outside sync-daemon',
323
- workspaceId,
324
- filePath: changedPath,
325
- timestamp: new Date().toISOString(),
326
- incidentId: crypto.createHash('sha1').update(changedPath + Date.now()).digest('hex').slice(0, 16),
327
- }) + '\n';
328
- const hookEventsJsonl = path.join(os.homedir(), '.intutic', 'events', 'hook-events.jsonl');
329
- fs.appendFileSync(hookEventsJsonl, tamperEntry, { flag: 'a' });
330
- }
331
- catch (err) {
332
- console.warn('[sync-daemon] onDriftDetected: failed to write tamper event (non-fatal):', err);
333
- }
334
- };
335
- let iterationCount = 0;
336
- while (!signal?.aborted) {
337
- try {
338
- // Refresh runtime env on every iteration (key rotation)
339
- const result = await runSyncIteration({
340
- controlPlaneUrl,
341
- apiKey,
342
- workspaceId,
343
- workspaceRoot,
344
- onSync,
345
- });
346
- // Update cached state for drift handler
347
- if (result) {
348
- latestSettings = result.settings;
349
- latestSops = result.sops ?? [];
350
- latestProxyUrl = result.proxyUrl ?? '';
351
- latestHarnesses = result.harnesses ?? [];
352
- latestGovernanceInputs = result.harnessGovernanceInputs ?? {};
353
- await writeRuntimeEnv({
354
- controlPlaneUrl,
355
- apiKey,
356
- workspaceId,
357
- mcpProxyFailBehavior: result.settings?.mcpProxyFailBehavior,
358
- mcpProxyMode: result.settings?.mcpProxyMode,
359
- bypassEnforcementTier: result.settings?.bypassEnforcementTier,
360
- valkeyUrl: trajectoryMonitor ? valkeyUrl : undefined,
361
- });
362
- // Same reasoning as Step 0b–0d: policy rides the sync cycle, not the
363
- // config version.
364
- await refreshGateCaches({
365
- controlPlaneUrl, apiKey, workspaceId,
366
- localHoldTokens: await localHoldTokensFor(workspaceRoot, latestSops, latestSettings, latestHarnesses),
367
- });
368
- // Governed decisions log — opt-in only (WorkspaceSettings.decisionsLogEnabled,
369
- // default off: a growing context file is token spend the product must not
370
- // silently impose on every workspace). No Step-0-equivalent unconditional
371
- // call before the loop, unlike egress/policy/bypasses above: this gate can
372
- // only be evaluated once `result.settings` is known, which is not yet true
373
- // before the first iteration completes. When disabled, this is simply not
374
- // called — existing files (if any, from before the workspace disabled the
375
- // feature) are left as last written rather than force-deleted or overwritten
376
- // with a stale/wrong "disabled" digest.
377
- if (result.settings?.decisionsLogEnabled) {
378
- await refreshDecisionsDigest({
379
- controlPlaneUrl,
380
- apiKey,
381
- workspaceId,
382
- workspaceRoot,
383
- harnesses: latestHarnesses,
384
- });
385
- }
386
- }
387
- // Start the drift watcher on first successful sync (once harnesses are known)
388
- if (!driftWatcher && latestHarnesses.length > 0) {
389
- try {
390
- driftWatcher = startWatcher(workspaceRoot, latestHarnesses, onDriftDetected);
391
- signal?.addEventListener('abort', () => driftWatcher?.stop(), { once: true });
392
- }
393
- catch (err) {
394
- console.warn('[sync-daemon] Could not start drift watcher (non-fatal):', err);
395
- }
396
- }
397
- // Run network compliance probes to check for proxy bypass
398
- try {
399
- const probeResults = await runComplianceProbes(workspaceId);
400
- for (const res of probeResults) {
401
- if (!res.contained && res.incident) {
402
- const entry = JSON.stringify(res.incident) + '\n';
403
- fs.appendFileSync(hookEventsLog, entry, { flag: 'a' });
404
- }
405
- }
406
- }
407
- catch (err) {
408
- console.warn('[sync-daemon] Compliance probes failed (non-fatal):', err);
409
- }
410
- // 4b. Capture harness configs back to control plane (every Nth iteration)
411
- if (shouldCaptureThisIteration(iterationCount)) {
412
- try {
413
- await captureAndUpload(controlPlaneUrl, apiKey, workspaceId, workspaceRoot, latestHarnesses, latestGovernanceInputs);
414
- }
415
- catch (err) {
416
- console.warn('[sync-daemon] Config capture failed (non-fatal):', err);
417
- }
418
- }
419
- iterationCount++;
420
- }
421
- catch (err) {
422
- console.error('[sync-daemon] iteration error:', err);
423
- }
424
- await sleep(pollIntervalMs, signal);
425
- }
426
- // Cleanup on exit
427
- fsWatcher?.close();
428
- driftWatcher?.stop();
429
- if (drainSafetyTimer)
430
- clearInterval(drainSafetyTimer);
431
- // Mark this run's harness sessions ended so the dashboard's live list
432
- // reflects reality.
433
- await endAllOpenSessions(controlPlaneUrl, apiKey);
434
- }
435
- /**
436
- * Execute a single sync iteration:
437
- * 1. Fetch config from control plane
438
- * 2. Write config files if version is newer
439
- * 3. Compute file hashes
440
- * 4. Report status back to control plane
441
- * 5. Update local integrity store
442
- * 6. Call onSync callback
443
- *
444
- * Exported (in addition to being used internally by `startSyncLoop`'s
445
- * polling loop) as a test seam — it is the unit that exercises exactly one
446
- * cycle's worth of work, including the injectMcpServer call (step 3c),
447
- * without the surrounding while-loop's extra one-time/Nth-iteration network
448
- * calls (policy snapshot, approved bypasses, egress policy, compliance
449
- * probes, drift watcher, decisions digest, config capture).
450
- */
451
- export async function runSyncIteration(ctx) {
452
- const { controlPlaneUrl, apiKey, workspaceId, workspaceRoot, onSync } = ctx;
453
- // 1. Fetch config from control plane
454
- const config = await fetchConfig(controlPlaneUrl, apiKey, workspaceId);
455
- // 2. Load local integrity store
456
- const integrity = await loadIntegrity(workspaceRoot);
457
- const localVersion = integrity?.configVersion ?? -1;
458
- let sopsWritten = 0;
459
- // 3. If remote version is newer → write config files
460
- if (config.configVersion > localVersion) {
461
- const harnesses = extractHarnesses(config);
462
- const writeResult = await writeConfigFiles(workspaceRoot, config.sops, config.proxyUrl, harnesses);
463
- sopsWritten = writeResult.filesWritten.length;
464
- }
465
- // 3b. Apply custom config edits from SkillOpt suggestions
466
- if (config.appliedEdits && config.appliedEdits.length > 0) {
467
- const forceApply = sopsWritten > 0;
468
- const appliedSuggestionsPath = path.join(workspaceRoot, '.intutic', 'applied-suggestions.json');
469
- let localAppliedIds = [];
470
- try {
471
- if (fs.existsSync(appliedSuggestionsPath)) {
472
- localAppliedIds = JSON.parse(fs.readFileSync(appliedSuggestionsPath, 'utf-8'));
473
- }
474
- }
475
- catch {
476
- // ignore
477
- }
478
- const toApply = forceApply
479
- ? config.appliedEdits
480
- : config.appliedEdits.filter(edit => !localAppliedIds.includes(edit.suggestionId));
481
- if (toApply.length > 0) {
482
- try {
483
- const results = await applyConfigEdits(workspaceRoot, toApply, config.settings.bypassEnforcementTier);
484
- // Ack every result back to control-plane (TD-349). Best-effort per
485
- // suggestion — one failed ack must not block the others or abort
486
- // the sync cycle; see reportApplyResult's own non-fatal handling.
487
- for (const result of results) {
488
- await reportApplyResult(controlPlaneUrl, apiKey, result.suggestionId, result);
489
- }
490
- // Only remember ids that fully landed (every operation for that
491
- // suggestion applied). Recording every ATTEMPTED id regardless of
492
- // outcome (the old behavior) meant a fuzzy-match miss was silently
493
- // never retried — the id was in applied-suggestions.json forever,
494
- // and forceApply is the only other thing that re-attempts an id
495
- // already in this file. A failed edit must remain retryable.
496
- const landedIds = results.filter((r) => r.ok).map((r) => r.suggestionId);
497
- const newlyAppliedIds = Array.from(new Set([...localAppliedIds, ...landedIds]));
498
- try {
499
- fs.mkdirSync(path.dirname(appliedSuggestionsPath), { recursive: true });
500
- fs.writeFileSync(appliedSuggestionsPath, JSON.stringify(newlyAppliedIds, null, 2), 'utf-8');
501
- }
502
- catch {
503
- // ignore
504
- }
505
- }
506
- catch (err) {
507
- console.warn('[sync-daemon] Failed to apply custom SkillOpt config edits:', err);
508
- }
509
- }
510
- }
511
- // 3c. Proxy-wrap MCP servers across all supported harness configs. This is
512
- // a continuous invariant, not a one-shot done only at `connect` time: a
513
- // server a user adds to a harness config after their first `connect` would
514
- // otherwise never get wrapped and stay invisible to governance. Safe to run
515
- // every cycle because mcpAutoWrite's writeJsonFile is write-if-changed — an
516
- // already-wrapped, unchanged config writes zero bytes. Non-fatal, matching
517
- // step 3b above: a failure here must not abort the sync cycle.
518
- try {
519
- await injectMcpServer(workspaceRoot, workspaceId);
520
- }
521
- catch (err) {
522
- console.warn('[sync-daemon] injectMcpServer failed (non-fatal):', err);
523
- }
524
- // 3d. Worktree propagation (O1). `workspaceRoot` above only ever covers the
525
- // ONE checkout this daemon was pointed at — a `git worktree add`-created
526
- // sibling checkout has its own independent working tree, and every
527
- // project-tier governance file this product writes is untracked, so none
528
- // of it exists in a worktree by default (see gitWorktrees.ts's module doc
529
- // for the full mechanics and why Xirp is what surfaced this). Discovering
530
- // worktrees fresh every cycle — never caching the list across
531
- // iterations — is what makes worktree REMOVAL fall out for free: a
532
- // worktree `git worktree remove`d since the last cycle simply stops being
533
- // returned here, with no stale-entry cleanup logic required.
534
- //
535
- // Config-content writes reuse the SAME `config.configVersion > localVersion`
536
- // gate step 3 already used — every worktree of one repo shares the one
537
- // control-plane config, so re-deriving per-worktree versioning would just
538
- // reproduce a decision already made above. `injectMcpServer` runs
539
- // unconditionally per worktree, same as it does for the main root: it is
540
- // write-if-changed internally (mcpAutoWrite's writeJsonFile), so an
541
- // unchanged worktree costs a few file reads and no writes.
542
- try {
543
- const worktrees = await discoverWorktrees(workspaceRoot);
544
- for (const worktreeRoot of worktrees) {
545
- if (worktreeRoot === workspaceRoot)
546
- continue; // already covered above
547
- try {
548
- if (config.configVersion > localVersion) {
549
- await writeConfigFiles(worktreeRoot, config.sops, config.proxyUrl, extractHarnesses(config));
550
- }
551
- await injectMcpServer(worktreeRoot, workspaceId);
552
- }
553
- catch (err) {
554
- console.warn(`[sync-daemon] worktree propagation failed for ${worktreeRoot} (non-fatal):`, err);
555
- }
556
- }
557
- }
558
- catch (err) {
559
- console.warn('[sync-daemon] discoverWorktrees failed (non-fatal):', err);
560
- }
561
- // 4. Compute file hashes for drift detection
562
- const harnesses = extractHarnesses(config);
563
- const canonicalHashes = buildCanonicalHashMap(config);
564
- const fileHashes = await computeFileHashes(workspaceRoot, harnesses, canonicalHashes);
565
- const driftCount = fileHashes.filter((f) => f.drifted).length;
566
- // 5. Report status back to control plane per active harness
567
- for (const harness of harnesses) {
568
- const filename = HARNESS_FILES[harness];
569
- if (!filename)
570
- continue;
571
- const harnessHashes = fileHashes.filter((f) => f.filePath === filename);
572
- if (harnessHashes.length > 0) {
573
- await reportStatus(controlPlaneUrl, apiKey, workspaceId, harness, harnessHashes);
574
- }
575
- }
576
- // 5b. Register each harness as a durable agent with its facets, so the
577
- // dashboard agent graph + posture ring stay live. Best-effort: a failed
578
- // report never blocks the sync loop.
579
- //
580
- // Skills scanned this cycle whose finding has already been emitted, keyed
581
- // `source:name`. `collectAgentReport` recomputes the same workspace-wide
582
- // skill scan once per harness in this loop — a skill is not harness-
583
- // specific — so without this a workspace with two active harnesses would
584
- // double-emit every flagged skill every cycle.
585
- const skillFlaggedThisCycle = new Set();
586
- // Per-harness governance-coverage enforcement signals for this cycle,
587
- // derived from the same facets `collectAgentReport` just computed — see
588
- // `SyncResult.harnessGovernanceInputs`'s doc comment. Uses the shared
589
- // `deriveEnforcementInputs` (`@intutic/shared-types`, TD-443) so this
590
- // mapping cannot drift from `harnessGradeSweep.ts`'s control-plane-side
591
- // sweep the way the two hand-kept copies previously did — safe to depend
592
- // on since `@intutic/shared-types` is a leaf package, not
593
- // `services/control-plane` itself, so this publicly-mirrored module still
594
- // never imports from the enterprise-only service.
595
- const harnessGovernanceInputs = {};
596
- // The local proxy's instance id, read once per iteration (not per harness):
597
- // step 5c registers each harness's git/task context onto that process's own
598
- // session row (TD-231, Wave 5.6). `null` — no local proxy, or a shared
599
- // gateway — keeps the `ses_` fallback. Skipped when there is no harness to
600
- // report, so a workspace with no SOP targets never probes the proxy.
601
- const proxyInstanceId = harnesses.length > 0 ? await fetchLocalProxyInstanceId() : null;
602
- for (const harness of harnesses) {
603
- const filename = HARNESS_FILES[harness];
604
- const configSynced = filename
605
- ? !fileHashes.some((f) => f.filePath === filename && f.drifted)
606
- : false;
607
- const report = await collectAgentReport({
608
- workspaceRoot,
609
- harnessType: harness,
610
- configSynced,
611
- dlpEnabled: true, // the daemon-managed proxy ships DLP on by default
612
- policyEnforced: Boolean(controlPlaneUrl),
613
- allowLocalVaults: config.settings?.allowLocalMemoryVaults,
614
- });
615
- await reportAgent(controlPlaneUrl, apiKey, workspaceId, report);
616
- harnessGovernanceInputs[harness] = deriveEnforcementInputs(report.facets);
617
- // 5b-i. Skill-content scan findings, surfaced as `skill_flagged` events
618
- // (TD-358). See `emitSkillFlaggedEvents`'s own doc comment for why this
619
- // rides the existing hook-events drain rather than a new pipeline.
620
- emitSkillFlaggedEvents({
621
- workspaceRoot,
622
- workspaceId,
623
- harnessType: harness,
624
- skills: report.facets.skills,
625
- alreadyEmitted: skillFlaggedThisCycle,
626
- });
627
- // 5c. Report the harness session (once per harness per proxy process —
628
- // the reporter dedupes). This is what switches on branch/commit capture
629
- // and the task-context cascade. With a local proxy the context lands on
630
- // the proxy's own row, the one its traces are filed under; without one a
631
- // `ses_` row is opened (see sessionReporter.ts).
632
- await startHarnessSession({
633
- controlPlaneUrl,
634
- apiKey,
635
- workspaceId,
636
- harnessType: harness,
637
- workspaceRoot,
638
- ...(proxyInstanceId ? { proxyInstanceId } : {}),
639
- });
640
- }
641
- // 6. Update local integrity store
642
- const newFiles = {};
643
- for (const fh of fileHashes) {
644
- if (fh.localHash) {
645
- newFiles[fh.filePath] = fh.localHash;
646
- }
647
- }
648
- await saveIntegrity(workspaceRoot, {
649
- lastSyncAt: newIso(),
650
- configVersion: config.configVersion,
651
- files: newFiles,
652
- });
653
- // 6b. ContextGraph indexing — REMOVED.
654
- //
655
- // This block scanned the whole workspace every sync cycle and POSTed the
656
- // delta to /api/v1/context/sync, an ingest stripped from the control plane
657
- // with the Context Graph feature (commit 9cfc0200). Worse than dead: the
658
- // integrity file is only written on a 2xx, so every cycle re-scanned and
659
- // re-posted the same delta forever on every developer machine.
660
- // 6c. Hook event drain is now FSEvents-driven (see startSyncLoop).
661
- // No per-iteration drain needed here; the chokidar watcher handles it.
662
- // 7. Invoke onSync callback
663
- const result = {
664
- configVersion: config.configVersion,
665
- sopsWritten,
666
- driftCount,
667
- timestamp: newIso(),
668
- settings: config.settings,
669
- sops: config.sops,
670
- proxyUrl: config.proxyUrl,
671
- harnesses,
672
- harnessGovernanceInputs,
673
- };
674
- onSync?.(result);
675
- return result;
676
- }
677
- // ─── HTTP helpers ────────────────────────────────────────────────────
678
- /**
679
- * Fetch the sync config from the control plane.
680
- *
681
- * POST /api/v1/sync/config
682
- */
683
- async function fetchConfig(controlPlaneUrl, apiKey, workspaceId) {
684
- const url = `${controlPlaneUrl}/api/v1/sync/config`;
685
- const res = await fetch(url, {
686
- method: 'POST',
687
- headers: {
688
- 'Content-Type': 'application/json',
689
- Authorization: `Bearer ${apiKey}`,
690
- },
691
- body: JSON.stringify({ workspaceId }),
692
- signal: AbortSignal.timeout(15_000), // 15s request timeout
693
- });
694
- if (!res.ok) {
695
- throw new Error(`[sync-daemon] fetchConfig failed: ${res.status} ${res.statusText}`);
696
- }
697
- return (await res.json());
698
- }
699
- /**
700
- * Report file hashes and status back to the control plane.
701
- *
702
- * POST /api/v1/sync/sop-hash
703
- */
704
- async function reportStatus(controlPlaneUrl, apiKey, workspaceId, harnessType, files) {
705
- const url = `${controlPlaneUrl}/api/v1/sync/sop-hash`;
706
- const res = await fetch(url, {
707
- method: 'POST',
708
- headers: {
709
- 'Content-Type': 'application/json',
710
- Authorization: `Bearer ${apiKey}`,
711
- },
712
- body: JSON.stringify({
713
- workspaceId,
714
- harnessType,
715
- files,
716
- reportedAt: newIso(),
717
- }),
718
- signal: AbortSignal.timeout(15_000),
719
- });
720
- if (!res.ok) {
721
- // Non-fatal: log but don't throw (control plane might be temporarily down)
722
- console.warn(`[sync-daemon] reportStatus failed for ${harnessType}: ${res.status} ${res.statusText}`);
723
- }
724
- }
725
- /**
726
- * Ack a SkillOpt config-edit apply outcome back to the control plane
727
- * (TD-349) — POST /api/v1/skillopt/:suggestionId/apply-result.
728
- *
729
- * Same HTTP call shape as `reportGovernanceCoverageSnapshot`
730
- * (configReader.ts): `fetch` + `AbortSignal.timeout(10_000)`, fully
731
- * non-fatal. This ack closing the loop is important, but it must never be
732
- * allowed to abort a sync cycle that otherwise succeeded — a dropped ack
733
- * just means the suggestion re-syncs and gets acked again next cycle
734
- * (getSyncConfig re-pushes 'applied' and 'apply_unconfirmed' rows alike).
735
- */
736
- async function reportApplyResult(controlPlaneUrl, apiKey, suggestionId, result) {
737
- try {
738
- const res = await fetch(`${controlPlaneUrl}/api/v1/skillopt/${suggestionId}/apply-result`, {
739
- method: 'POST',
740
- headers: {
741
- 'Content-Type': 'application/json',
742
- Authorization: `Bearer ${apiKey}`,
743
- },
744
- body: JSON.stringify({
745
- ok: result.ok,
746
- perOperation: result.perOperation,
747
- }),
748
- signal: AbortSignal.timeout(10_000),
749
- });
750
- if (!res.ok) {
751
- const body = await res.text().catch(() => '');
752
- console.warn(`[sync-daemon] reportApplyResult failed for ${suggestionId} (${res.status}): ${body}`);
753
- }
754
- }
755
- catch (err) {
756
- console.warn(`[sync-daemon] reportApplyResult request failed for ${suggestionId}:`, err);
757
- }
758
- }
759
- // ─── Helpers ─────────────────────────────────────────────────────────
760
- /**
761
- * Extract unique harness types from a sync config payload.
762
- */
763
- function extractHarnesses(config) {
764
- const set = new Set();
765
- for (const sop of config.sops) {
766
- for (const h of sop.harnessTargets) {
767
- set.add(h);
768
- }
769
- }
770
- return [...set];
771
- }
772
- /**
773
- * Build a filePath → canonical hash map from the config's SOPs.
774
- * Uses the first canonical hash found for each file path.
775
- */
776
- function buildCanonicalHashMap(config) {
777
- const map = {};
778
- for (const sop of config.sops) {
779
- // Each SOP has a single contentHash; in a multi-harness scenario
780
- // the same hash applies to the content source (not the formatted output).
781
- // For per-file canonical hashes, the control plane would need to
782
- // provide them per-harness. For now we use sop.contentHash as a
783
- // proxy keyed by sopId.
784
- map[sop.sopId] = sop.contentHash;
785
- }
786
- return map;
787
- }
788
- /**
789
- * Sleep for the given duration with AbortSignal support.
790
- *
791
- * Resolves immediately if the signal is already aborted.
792
- * Otherwise, resolves after `ms` milliseconds or when the
793
- * signal fires, whichever comes first.
794
- */
795
- function sleep(ms, signal) {
796
- if (signal?.aborted)
797
- return Promise.resolve();
798
- return new Promise((resolve) => {
799
- const timer = setTimeout(() => {
800
- signal?.removeEventListener('abort', onAbort);
801
- resolve();
802
- }, ms);
803
- function onAbort() {
804
- clearTimeout(timer);
805
- resolve();
806
- }
807
- signal?.addEventListener('abort', onAbort, { once: true });
808
- });
809
- }
810
- export async function syncOfflineTraces(controlPlaneUrl, apiKey) {
811
- const logsDir = path.join(os.homedir(), '.intutic', 'logs');
812
- if (!fs.existsSync(logsDir))
813
- return;
814
- try {
815
- const files = fs.readdirSync(logsDir);
816
- const traceFiles = files.filter(f => f.startsWith('traces-') && f.endsWith('.jsonl'));
817
- if (traceFiles.length === 0)
818
- return;
819
- for (const file of traceFiles) {
820
- const originalPath = path.join(logsDir, file);
821
- const syncingPath = originalPath + '.syncing';
822
- // Rename to avoid write race conditions with the Rust proxy
823
- try {
824
- fs.renameSync(originalPath, syncingPath);
825
- }
826
- catch (renameErr) {
827
- console.error(`[sync-daemon] Failed to lock/rename file ${file}:`, errorMessage(renameErr));
828
- continue;
829
- }
830
- try {
831
- const raw = fs.readFileSync(syncingPath, 'utf-8');
832
- const lines = raw.split('\n').map(l => l.trim()).filter(Boolean);
833
- if (lines.length === 0) {
834
- // Empty file, just clean it up
835
- fs.unlinkSync(syncingPath);
836
- continue;
837
- }
838
- const traces = lines.map(line => JSON.parse(line));
839
- console.log(`[sync-daemon] Found ${traces.length} offline traces to sync back in ${file}.`);
840
- // Batch in groups of 100
841
- const batchSize = 100;
842
- for (let i = 0; i < traces.length; i += batchSize) {
843
- const batch = traces.slice(i, i + batchSize);
844
- const res = await fetch(`${controlPlaneUrl}/api/v1/traces/sync-back`, {
845
- method: 'POST',
846
- headers: {
847
- 'Content-Type': 'application/json',
848
- Authorization: `Bearer ${apiKey}`,
849
- },
850
- body: JSON.stringify({ traces: batch }),
851
- });
852
- if (!res.ok) {
853
- throw new Error(`Sync-back API returned status ${res.status}`);
854
- }
855
- }
856
- // Successfully synced this sharded file, delete it
857
- fs.unlinkSync(syncingPath);
858
- console.log(`[sync-daemon] Successfully synced ${traces.length} offline traces back from ${file}.`);
859
- }
860
- catch (err) {
861
- console.error(`[sync-daemon] Failed to sync offline traces back from ${file}:`, errorMessage(err));
862
- // Revert rename on failure to allow retry on next cycle
863
- try {
864
- if (fs.existsSync(syncingPath)) {
865
- fs.renameSync(syncingPath, originalPath);
866
- }
867
- }
868
- catch (revertErr) {
869
- console.error(`[sync-daemon] Failed to revert rename for ${file}:`, errorMessage(revertErr));
870
- }
871
- }
872
- }
873
- }
874
- catch (err) {
875
- console.error(`[sync-daemon] Failed to scan offline traces directory:`, errorMessage(err));
876
- }
877
- }
878
- //# sourceMappingURL=syncLoop.js.map