@intutic/sync-daemon 2.1.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.
- package/dist/agentReporter.d.ts +1 -0
- package/dist/agentReporter.d.ts.map +1 -1
- package/dist/configReader.d.ts +44 -22
- package/dist/configReader.d.ts.map +1 -1
- package/dist/configReader.js +70 -44
- package/dist/configReader.js.map +1 -1
- package/dist/configWriter.d.ts +29 -41
- package/dist/configWriter.d.ts.map +1 -1
- package/dist/configWriter.js +26 -637
- package/dist/configWriter.js.map +1 -1
- package/dist/disconnect/harnesses.d.ts +31 -0
- package/dist/disconnect/harnesses.d.ts.map +1 -0
- package/dist/disconnect/harnesses.js +728 -0
- package/dist/disconnect/harnesses.js.map +1 -0
- package/dist/disconnect/index.d.ts +25 -0
- package/dist/disconnect/index.d.ts.map +1 -0
- package/dist/disconnect/index.js +82 -0
- package/dist/disconnect/index.js.map +1 -0
- package/dist/disconnect/mcp.d.ts +60 -0
- package/dist/disconnect/mcp.d.ts.map +1 -0
- package/dist/disconnect/mcp.js +283 -0
- package/dist/disconnect/mcp.js.map +1 -0
- package/dist/disconnect/originals.d.ts +88 -0
- package/dist/disconnect/originals.d.ts.map +1 -0
- package/dist/disconnect/originals.js +257 -0
- package/dist/disconnect/originals.js.map +1 -0
- package/dist/disconnect/plan.d.ts +175 -0
- package/dist/disconnect/plan.d.ts.map +1 -0
- package/dist/disconnect/plan.js +580 -0
- package/dist/disconnect/plan.js.map +1 -0
- package/dist/disconnect/recognise.d.ts +43 -0
- package/dist/disconnect/recognise.d.ts.map +1 -0
- package/dist/disconnect/recognise.js +98 -0
- package/dist/disconnect/recognise.js.map +1 -0
- package/dist/disconnect/tomlLines.d.ts +39 -0
- package/dist/disconnect/tomlLines.d.ts.map +1 -0
- package/dist/disconnect/tomlLines.js +105 -0
- package/dist/disconnect/tomlLines.js.map +1 -0
- package/dist/harness/aiderConfigMerger.d.ts +1 -0
- package/dist/harness/aiderConfigMerger.d.ts.map +1 -1
- package/dist/harness/aiderConfigMerger.js +4 -1
- package/dist/harness/aiderConfigMerger.js.map +1 -1
- package/dist/harness/antigravityHooks.d.ts.map +1 -1
- package/dist/harness/antigravityHooks.js +2 -0
- package/dist/harness/antigravityHooks.js.map +1 -1
- package/dist/harness/claudeCodeHooks.d.ts +1 -2
- package/dist/harness/claudeCodeHooks.d.ts.map +1 -1
- package/dist/harness/claudeCodeHooks.js +19 -6
- package/dist/harness/claudeCodeHooks.js.map +1 -1
- package/dist/harness/claudeDesktopHooks.d.ts +3 -0
- package/dist/harness/claudeDesktopHooks.d.ts.map +1 -1
- package/dist/harness/claudeDesktopHooks.js +3 -1
- package/dist/harness/claudeDesktopHooks.js.map +1 -1
- package/dist/harness/claudeProjectApproval.d.ts +53 -0
- package/dist/harness/claudeProjectApproval.d.ts.map +1 -0
- package/dist/harness/claudeProjectApproval.js +159 -0
- package/dist/harness/claudeProjectApproval.js.map +1 -0
- package/dist/harness/clineHooks.d.ts.map +1 -1
- package/dist/harness/clineHooks.js +3 -0
- package/dist/harness/clineHooks.js.map +1 -1
- package/dist/harness/codexConfigMerger.d.ts.map +1 -1
- package/dist/harness/codexConfigMerger.js +4 -0
- package/dist/harness/codexConfigMerger.js.map +1 -1
- package/dist/harness/codexHooks.d.ts.map +1 -1
- package/dist/harness/codexHooks.js +3 -0
- package/dist/harness/codexHooks.js.map +1 -1
- package/dist/harness/continueConfigMerger.d.ts.map +1 -1
- package/dist/harness/continueConfigMerger.js +4 -0
- package/dist/harness/continueConfigMerger.js.map +1 -1
- package/dist/harness/continueHooks.d.ts.map +1 -1
- package/dist/harness/continueHooks.js +3 -0
- package/dist/harness/continueHooks.js.map +1 -1
- package/dist/harness/cursorHooks.d.ts.map +1 -1
- package/dist/harness/cursorHooks.js +3 -0
- package/dist/harness/cursorHooks.js.map +1 -1
- package/dist/harness/dshHooks.d.ts.map +1 -1
- package/dist/harness/dshHooks.js +7 -0
- package/dist/harness/dshHooks.js.map +1 -1
- package/dist/harness/gateBody.d.ts +11 -2
- package/dist/harness/gateBody.d.ts.map +1 -1
- package/dist/harness/gateBody.js +33 -6
- package/dist/harness/gateBody.js.map +1 -1
- package/dist/harness/githubCopilotHooks.d.ts.map +1 -1
- package/dist/harness/githubCopilotHooks.js +4 -9
- package/dist/harness/githubCopilotHooks.js.map +1 -1
- package/dist/harness/gooseHooks.d.ts +15 -0
- package/dist/harness/gooseHooks.d.ts.map +1 -1
- package/dist/harness/gooseHooks.js +55 -43
- package/dist/harness/gooseHooks.js.map +1 -1
- package/dist/harness/grokHooks.d.ts.map +1 -1
- package/dist/harness/grokHooks.js +9 -12
- package/dist/harness/grokHooks.js.map +1 -1
- package/dist/harness/hermesHooks.d.ts.map +1 -1
- package/dist/harness/hermesHooks.js +7 -2
- package/dist/harness/hermesHooks.js.map +1 -1
- package/dist/harness/holdRedaction.d.ts +4 -1
- package/dist/harness/holdRedaction.d.ts.map +1 -1
- package/dist/harness/holdRedaction.js +32 -8
- package/dist/harness/holdRedaction.js.map +1 -1
- package/dist/harness/jetbrainsXmlConfig.d.ts +20 -0
- package/dist/harness/jetbrainsXmlConfig.d.ts.map +1 -1
- package/dist/harness/jetbrainsXmlConfig.js +39 -18
- package/dist/harness/jetbrainsXmlConfig.js.map +1 -1
- package/dist/harness/mcpAutoWrite.d.ts +39 -17
- package/dist/harness/mcpAutoWrite.d.ts.map +1 -1
- package/dist/harness/mcpAutoWrite.js +189 -71
- package/dist/harness/mcpAutoWrite.js.map +1 -1
- package/dist/harness/museHooks.d.ts.map +1 -1
- package/dist/harness/museHooks.js +6 -2
- package/dist/harness/museHooks.js.map +1 -1
- package/dist/harness/openCodeHooks.d.ts.map +1 -1
- package/dist/harness/openCodeHooks.js +2 -5
- package/dist/harness/openCodeHooks.js.map +1 -1
- package/dist/harness/openWebuiHooks.d.ts.map +1 -1
- package/dist/harness/openWebuiHooks.js +6 -2
- package/dist/harness/openWebuiHooks.js.map +1 -1
- package/dist/harness/openclawHooks.d.ts +5 -0
- package/dist/harness/openclawHooks.d.ts.map +1 -1
- package/dist/harness/openclawHooks.js +3 -1
- package/dist/harness/openclawHooks.js.map +1 -1
- package/dist/harness/openhandsHooks.d.ts +13 -1
- package/dist/harness/openhandsHooks.d.ts.map +1 -1
- package/dist/harness/openhandsHooks.js +106 -56
- package/dist/harness/openhandsHooks.js.map +1 -1
- package/dist/harness/piHooks.d.ts.map +1 -1
- package/dist/harness/piHooks.js +3 -0
- package/dist/harness/piHooks.js.map +1 -1
- package/dist/harness/rooCodeHooks.d.ts.map +1 -1
- package/dist/harness/rooCodeHooks.js +5 -10
- package/dist/harness/rooCodeHooks.js.map +1 -1
- package/dist/harness/windsurfHooks.d.ts +18 -2
- package/dist/harness/windsurfHooks.d.ts.map +1 -1
- package/dist/harness/windsurfHooks.js +37 -7
- package/dist/harness/windsurfHooks.js.map +1 -1
- package/dist/harness/windsurfJetBrainsProxy.d.ts +1 -1
- package/dist/harness/windsurfJetBrainsProxy.d.ts.map +1 -1
- package/dist/harness/windsurfJetBrainsProxy.js +5 -1
- package/dist/harness/windsurfJetBrainsProxy.js.map +1 -1
- package/dist/index.d.ts +9 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -10
- package/dist/index.js.map +1 -1
- package/dist/lib/decisionsDigest.d.ts +1 -1
- package/dist/lib/decisionsDigest.js +1 -1
- package/dist/lib/policySnapshot.d.ts +18 -0
- package/dist/lib/policySnapshot.d.ts.map +1 -1
- package/dist/lib/policySnapshot.js +142 -7
- package/dist/lib/policySnapshot.js.map +1 -1
- package/dist/sessionReporter.d.ts +2 -2
- package/dist/sessionReporter.js +2 -2
- package/dist/syncCycle.d.ts +142 -0
- package/dist/syncCycle.d.ts.map +1 -0
- package/dist/syncCycle.js +315 -0
- package/dist/syncCycle.js.map +1 -0
- package/dist/trajectoryMonitor.d.ts +1 -1
- package/dist/trajectoryMonitor.d.ts.map +1 -1
- package/dist/trajectoryMonitor.js +1 -1
- package/dist/trajectoryMonitor.js.map +1 -1
- package/dist/watcher/settingsGuard.d.ts +4 -1
- package/dist/watcher/settingsGuard.d.ts.map +1 -1
- package/dist/watcher/settingsGuard.js +28 -3
- package/dist/watcher/settingsGuard.js.map +1 -1
- package/package.json +5 -5
- package/dist/hashReporter.d.ts +0 -33
- package/dist/hashReporter.d.ts.map +0 -1
- package/dist/hashReporter.js +0 -69
- package/dist/hashReporter.js.map +0 -1
- package/dist/integrityStore.d.ts +0 -35
- package/dist/integrityStore.d.ts.map +0 -1
- package/dist/integrityStore.js +0 -71
- package/dist/integrityStore.js.map +0 -1
- package/dist/lib/gitWorktrees.d.ts +0 -140
- package/dist/lib/gitWorktrees.d.ts.map +0 -1
- package/dist/lib/gitWorktrees.js +0 -191
- package/dist/lib/gitWorktrees.js.map +0 -1
- package/dist/syncLoop.d.ts +0 -172
- package/dist/syncLoop.d.ts.map +0 -1
- package/dist/syncLoop.js +0 -878
- 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
|