@ericsanchezok/synergy-workbench 0.0.0-stage → 3.1.1
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/AGENTS.md +30 -0
- package/README.md +16 -2
- package/dist/agents.js +11 -0
- package/dist/cli-adapter.js +8 -0
- package/dist/component.js +65 -0
- package/dist/config-schema.js +265 -0
- package/dist/execution/activity.js +78 -0
- package/dist/execution/content.js +184 -0
- package/dist/execution/json-sections.js +231 -0
- package/dist/execution/presentation.js +79 -0
- package/dist/execution/preview.js +13 -0
- package/dist/execution/process.js +125 -0
- package/dist/execution/projection.js +327 -0
- package/dist/execution/query.js +18 -0
- package/dist/execution/routes.js +189 -0
- package/dist/execution/schema.js +322 -0
- package/dist/execution/service.js +1993 -0
- package/dist/features.js +22 -0
- package/dist/http.js +65 -0
- package/dist/performance/analysis.js +330 -0
- package/dist/performance/catalog.js +391 -0
- package/dist/performance/dashboard.js +483 -0
- package/dist/performance/error.js +15 -0
- package/dist/performance/index.js +6 -0
- package/dist/performance/inflight.js +43 -0
- package/dist/performance/projection.js +138 -0
- package/dist/performance/prompt/base.txt +38 -0
- package/dist/performance/prompt/builder.js +21 -0
- package/dist/performance/routes/performance-route.js +439 -0
- package/dist/performance/schema.js +628 -0
- package/dist/performance/timeline.js +98 -0
- package/dist/performance/trace-detail.js +91 -0
- package/dist/presentation/service.js +270 -0
- package/dist/presentation/tools/workbench-view.js +65 -0
- package/dist/presentation/tools.js +10 -0
- package/dist/project/directories.js +363 -0
- package/dist/project/git-health.js +498 -0
- package/dist/project/ide.js +66 -0
- package/dist/project/migration.js +22 -0
- package/dist/project/routes/directories.js +89 -0
- package/dist/project/routes/git.js +30 -0
- package/dist/project/routes/task-defaults.js +42 -0
- package/dist/project/session-health.js +9 -0
- package/dist/project/startup.js +28 -0
- package/dist/project/task-defaults.js +68 -0
- package/dist/project/tools/session-control.js +664 -0
- package/dist/project/tools/session-control.txt +60 -0
- package/dist/project/tools/worktree-archive.js +76 -0
- package/dist/project/tools/worktree-enter.js +175 -0
- package/dist/project/tools/worktree-leave.js +119 -0
- package/dist/project/tools/worktree-list.js +52 -0
- package/dist/project/tools.js +26 -0
- package/dist/project/vcs-branch-watcher.js +76 -0
- package/dist/project/vcs.js +114 -0
- package/dist/project/worktree-janitor.js +140 -0
- package/dist/project/worktrees.js +98 -0
- package/dist/push/bridge.js +171 -0
- package/dist/push/index.js +4 -0
- package/dist/push/routes/push.js +224 -0
- package/dist/push/service.js +93 -0
- package/dist/push/store.js +121 -0
- package/dist/push/types.js +58 -0
- package/dist/review/git.js +226 -0
- package/dist/review/routes.js +45 -0
- package/dist/review/schema.js +32 -0
- package/dist/review/state.js +79 -0
- package/dist/stats/aggregator.js +252 -0
- package/dist/stats/canonical.js +89 -0
- package/dist/stats/cli/stats.js +302 -0
- package/dist/stats/engine.js +176 -0
- package/dist/stats/index.js +5 -0
- package/dist/stats/rollup.js +460 -0
- package/dist/stats/routes/stats.js +94 -0
- package/dist/stats/routes/usage.js +83 -0
- package/dist/stats/storage.js +86 -0
- package/dist/stats/types.js +267 -0
- package/dist/worker.js +6 -0
- package/package.json +232 -4
- package/src/agents.ts +15 -0
- package/src/cli-adapter.ts +11 -0
- package/src/component.ts +73 -0
- package/src/config-schema.ts +289 -0
- package/src/execution/activity.ts +83 -0
- package/src/execution/content.ts +189 -0
- package/src/execution/json-sections.ts +230 -0
- package/src/execution/presentation.ts +91 -0
- package/src/execution/preview.ts +12 -0
- package/src/execution/process.ts +118 -0
- package/src/execution/projection.ts +330 -0
- package/src/execution/query.ts +19 -0
- package/src/execution/routes.ts +324 -0
- package/src/execution/schema.ts +336 -0
- package/src/execution/service.ts +2149 -0
- package/src/features.ts +22 -0
- package/src/http.ts +75 -0
- package/src/performance/analysis.ts +356 -0
- package/src/performance/catalog.ts +902 -0
- package/src/performance/dashboard.ts +521 -0
- package/src/performance/error.ts +32 -0
- package/src/performance/index.ts +6 -0
- package/src/performance/inflight.ts +46 -0
- package/src/performance/projection.ts +143 -0
- package/src/performance/prompt/base.txt +38 -0
- package/src/performance/prompt/builder.ts +24 -0
- package/src/performance/routes/performance-route.ts +541 -0
- package/src/performance/schema.ts +695 -0
- package/src/performance/timeline.ts +100 -0
- package/src/performance/trace-detail.ts +97 -0
- package/src/presentation/service.ts +303 -0
- package/src/presentation/tools/workbench-view.ts +75 -0
- package/src/presentation/tools.ts +11 -0
- package/src/project/directories.ts +391 -0
- package/src/project/git-health.ts +577 -0
- package/src/project/ide.ts +80 -0
- package/src/project/migration.ts +24 -0
- package/src/project/routes/directories.ts +124 -0
- package/src/project/routes/git.ts +42 -0
- package/src/project/routes/task-defaults.ts +50 -0
- package/src/project/session-health.ts +10 -0
- package/src/project/startup.ts +28 -0
- package/src/project/task-defaults.ts +70 -0
- package/src/project/tools/session-control.ts +725 -0
- package/src/project/tools/session-control.txt +60 -0
- package/src/project/tools/worktree-archive.ts +92 -0
- package/src/project/tools/worktree-enter.ts +227 -0
- package/src/project/tools/worktree-leave.ts +140 -0
- package/src/project/tools/worktree-list.ts +68 -0
- package/src/project/tools.ts +29 -0
- package/src/project/vcs-branch-watcher.ts +81 -0
- package/src/project/vcs.ts +124 -0
- package/src/project/worktree-janitor.ts +153 -0
- package/src/project/worktrees.ts +110 -0
- package/src/push/bridge.ts +182 -0
- package/src/push/index.ts +4 -0
- package/src/push/routes/push.ts +267 -0
- package/src/push/service.ts +108 -0
- package/src/push/store.ts +120 -0
- package/src/push/types.ts +67 -0
- package/src/review/git.ts +221 -0
- package/src/review/routes.ts +65 -0
- package/src/review/schema.ts +34 -0
- package/src/review/state.ts +77 -0
- package/src/stats/aggregator.ts +277 -0
- package/src/stats/canonical.ts +93 -0
- package/src/stats/cli/stats.ts +348 -0
- package/src/stats/engine.ts +197 -0
- package/src/stats/index.ts +5 -0
- package/src/stats/rollup.ts +522 -0
- package/src/stats/routes/stats.ts +107 -0
- package/src/stats/routes/usage.ts +103 -0
- package/src/stats/storage.ts +78 -0
- package/src/stats/types.ts +351 -0
- package/src/worker.ts +7 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import path from "path"
|
|
2
|
+
|
|
3
|
+
export namespace VcsBranchWatcher {
|
|
4
|
+
export interface Watcher {
|
|
5
|
+
start(): Promise<string | undefined>
|
|
6
|
+
current(): string | undefined
|
|
7
|
+
notify(file: string): void
|
|
8
|
+
idle(): Promise<void>
|
|
9
|
+
dispose(): Promise<void>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function create(input: {
|
|
13
|
+
debounceMs: number
|
|
14
|
+
resolve: () => Promise<string | undefined>
|
|
15
|
+
onChange: (branch: string | undefined, previous: string | undefined) => Promise<void> | void
|
|
16
|
+
}): Watcher {
|
|
17
|
+
let branch: string | undefined
|
|
18
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
19
|
+
let inFlight: Promise<void> | undefined
|
|
20
|
+
let refreshPending = false
|
|
21
|
+
let disposed = false
|
|
22
|
+
const idleWaiters = new Set<() => void>()
|
|
23
|
+
|
|
24
|
+
const settled = () => !timer && !inFlight
|
|
25
|
+
const resolveIdle = () => {
|
|
26
|
+
if (!settled()) return
|
|
27
|
+
for (const resolve of idleWaiters) resolve()
|
|
28
|
+
idleWaiters.clear()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const refresh = () => {
|
|
32
|
+
if (disposed || inFlight) return
|
|
33
|
+
inFlight = Promise.resolve()
|
|
34
|
+
.then(async () => {
|
|
35
|
+
while (!disposed && refreshPending) {
|
|
36
|
+
refreshPending = false
|
|
37
|
+
const previous = branch
|
|
38
|
+
const next = await input.resolve()
|
|
39
|
+
branch = next
|
|
40
|
+
if (next !== previous) await input.onChange(next, previous)
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
.finally(() => {
|
|
44
|
+
inFlight = undefined
|
|
45
|
+
if (!disposed && refreshPending) refresh()
|
|
46
|
+
resolveIdle()
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
async start() {
|
|
52
|
+
branch = await input.resolve()
|
|
53
|
+
return branch
|
|
54
|
+
},
|
|
55
|
+
current() {
|
|
56
|
+
return branch
|
|
57
|
+
},
|
|
58
|
+
notify(file) {
|
|
59
|
+
if (disposed || path.basename(file) !== "HEAD") return
|
|
60
|
+
refreshPending = true
|
|
61
|
+
if (timer || inFlight) return
|
|
62
|
+
timer = setTimeout(() => {
|
|
63
|
+
timer = undefined
|
|
64
|
+
refresh()
|
|
65
|
+
}, input.debounceMs)
|
|
66
|
+
},
|
|
67
|
+
idle() {
|
|
68
|
+
if (settled()) return Promise.resolve()
|
|
69
|
+
return new Promise<void>((resolve) => idleWaiters.add(resolve))
|
|
70
|
+
},
|
|
71
|
+
async dispose() {
|
|
72
|
+
disposed = true
|
|
73
|
+
if (timer) clearTimeout(timer)
|
|
74
|
+
refreshPending = false
|
|
75
|
+
timer = undefined
|
|
76
|
+
await inFlight
|
|
77
|
+
resolveIdle()
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { WorkspaceEvents } from "@ericsanchezok/synergy-harness/workspace/events"
|
|
2
|
+
import { AsyncLocalStorage } from "node:async_hooks"
|
|
3
|
+
import { EnvironmentResources } from "@ericsanchezok/synergy-harness/environment/resources"
|
|
4
|
+
import { WorkspaceCatalog } from "@ericsanchezok/synergy-harness/workspace"
|
|
5
|
+
import { BusEvent } from "@ericsanchezok/synergy-harness/bus/bus-event"
|
|
6
|
+
import { $ } from "bun"
|
|
7
|
+
import path from "path"
|
|
8
|
+
import { z } from "zod"
|
|
9
|
+
import { Log } from "@ericsanchezok/synergy-harness/util/log"
|
|
10
|
+
import { WorkspaceState } from "@ericsanchezok/synergy-harness/workspace/state"
|
|
11
|
+
import { FileWatcher } from "@ericsanchezok/synergy-local-runtime/file/watcher"
|
|
12
|
+
import { Filesystem } from "@ericsanchezok/synergy-harness/util/filesystem"
|
|
13
|
+
import { VcsBranchWatcher } from "./vcs-branch-watcher"
|
|
14
|
+
import { FileView } from "@ericsanchezok/synergy-local-runtime/file/view"
|
|
15
|
+
import { WorktreeProcess } from "@ericsanchezok/synergy-local-runtime/workspace/process"
|
|
16
|
+
|
|
17
|
+
const log = Log.create({ service: "vcs" })
|
|
18
|
+
|
|
19
|
+
export namespace Vcs {
|
|
20
|
+
export const Event = {
|
|
21
|
+
BranchUpdated: BusEvent.define(
|
|
22
|
+
"vcs.branch.updated",
|
|
23
|
+
z.object({
|
|
24
|
+
...WorkspaceEvents.Fields,
|
|
25
|
+
branch: z.string().optional(),
|
|
26
|
+
}),
|
|
27
|
+
),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const Info = z
|
|
31
|
+
.object({
|
|
32
|
+
branch: z.string(),
|
|
33
|
+
})
|
|
34
|
+
.meta({
|
|
35
|
+
ref: "VcsInfo",
|
|
36
|
+
})
|
|
37
|
+
export type Info = z.infer<typeof Info>
|
|
38
|
+
|
|
39
|
+
async function currentBranch() {
|
|
40
|
+
const query = async () => {
|
|
41
|
+
const result = await WorktreeProcess.run({
|
|
42
|
+
command: ["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
43
|
+
directory: FileView.directory(),
|
|
44
|
+
roots: [],
|
|
45
|
+
metadata: true,
|
|
46
|
+
env: { GIT_OPTIONAL_LOCKS: "0" },
|
|
47
|
+
})
|
|
48
|
+
return result.exitCode === 0 ? result.stdout.toString().trim() : undefined
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
if (FileView.native()) return await query()
|
|
52
|
+
const owner = WorkspaceState.current()!
|
|
53
|
+
const workspace = await WorkspaceCatalog.get(owner.id!, owner.scopeID)
|
|
54
|
+
if (workspace.binding.generation !== owner.generation || workspace.activeMount?.state !== "active") return
|
|
55
|
+
await using resources = await EnvironmentResources.resolve({
|
|
56
|
+
scopeID: owner.scopeID,
|
|
57
|
+
workspaceID: owner.id,
|
|
58
|
+
workspaceGeneration: owner.generation,
|
|
59
|
+
needs: { workspace: true },
|
|
60
|
+
})
|
|
61
|
+
return await EnvironmentResources.provide(resources, `vcs:${crypto.randomUUID()}`, query)
|
|
62
|
+
} catch (error) {
|
|
63
|
+
log.debug("branch unavailable", { error })
|
|
64
|
+
return undefined
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const state = WorkspaceState.create(
|
|
69
|
+
async () => {
|
|
70
|
+
const watcher = VcsBranchWatcher.create({
|
|
71
|
+
debounceMs: 50,
|
|
72
|
+
resolve: AsyncLocalStorage.bind(currentBranch),
|
|
73
|
+
onChange: (branch, previous) => {
|
|
74
|
+
log.info("branch changed", { from: previous, to: branch })
|
|
75
|
+
WorkspaceEvents.publish(Event.BranchUpdated, { branch })
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
const current = await watcher.start()
|
|
79
|
+
log.info("initialized", { branch: current })
|
|
80
|
+
|
|
81
|
+
const unsubscribe = WorkspaceEvents.subscribe(FileWatcher.Event.Updated, (event) => {
|
|
82
|
+
watcher.notify(event.properties.resync ? ".git/HEAD" : event.properties.file)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
branch: async () => watcher.current(),
|
|
87
|
+
unsubscribe,
|
|
88
|
+
watcher,
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
async (state) => {
|
|
92
|
+
state.unsubscribe?.()
|
|
93
|
+
await state.watcher?.dispose()
|
|
94
|
+
},
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
export async function init() {
|
|
98
|
+
return state()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function branch() {
|
|
102
|
+
return await state().then((s) => s.branch())
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function initIfNeeded(directory: string, options?: { searchParents?: boolean }) {
|
|
106
|
+
const gitDir = path.join(directory, ".git")
|
|
107
|
+
const stat = await Bun.file(gitDir)
|
|
108
|
+
.stat()
|
|
109
|
+
.catch(() => undefined)
|
|
110
|
+
if (stat) return false
|
|
111
|
+
|
|
112
|
+
if (options?.searchParents !== false) {
|
|
113
|
+
const matches = Filesystem.up({ targets: [".git"], start: directory })
|
|
114
|
+
const found = await matches.next().then((x) => x.value)
|
|
115
|
+
await matches.return()
|
|
116
|
+
if (found) return false
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
log.info("initializing git repository", { directory })
|
|
120
|
+
await $`git init`.cwd(directory).quiet()
|
|
121
|
+
await $`git commit --allow-empty -m "Initial commit"`.cwd(directory).quiet()
|
|
122
|
+
return true
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { RuntimeContext } from "@ericsanchezok/synergy-harness/lifecycle/context"
|
|
2
|
+
import type { Scope } from "@ericsanchezok/synergy-harness/scope"
|
|
3
|
+
import { ScopeContext } from "@ericsanchezok/synergy-harness/scope/context"
|
|
4
|
+
import { Log } from "@ericsanchezok/synergy-harness/util/log"
|
|
5
|
+
import { readWorktreeConfig } from "@ericsanchezok/synergy-local-runtime/config-schema"
|
|
6
|
+
import { ProjectWorktrees } from "./worktrees"
|
|
7
|
+
import { Worktree } from "@ericsanchezok/synergy-local-runtime/workspace/worktree"
|
|
8
|
+
|
|
9
|
+
const log = Log.create({ service: "worktree-janitor" })
|
|
10
|
+
|
|
11
|
+
const DEFAULT_SWEEP_INTERVAL_HOURS = 6
|
|
12
|
+
|
|
13
|
+
interface Schedule {
|
|
14
|
+
first: ReturnType<typeof setTimeout>
|
|
15
|
+
interval: ReturnType<typeof setInterval>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const runtimeState = RuntimeContext.state(() => ({
|
|
19
|
+
schedules: new Map<string, Schedule>(),
|
|
20
|
+
sweeping: new Map<string, Promise<void>>(),
|
|
21
|
+
rerunRequested: new Set<string>(),
|
|
22
|
+
}))
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Background reaper for managed git worktrees.
|
|
26
|
+
*
|
|
27
|
+
* The first scan is scheduled with `setTimeout(0)` rather than awaited, so a
|
|
28
|
+
* slow or failing sweep never delays scope startup. Both timers are unref'd: a
|
|
29
|
+
* janitor must not be the reason the process stays alive. Sweeps are mutually
|
|
30
|
+
* exclusive per scope, because a tick that outlives its interval would
|
|
31
|
+
* otherwise let two sweeps race on the same worktree.
|
|
32
|
+
*/
|
|
33
|
+
export async function startWorktreeJanitor(scope: Scope.Project) {
|
|
34
|
+
const instanceState = runtimeState()
|
|
35
|
+
|
|
36
|
+
// The cap owner asks for a sweep after each creation; routing that request back
|
|
37
|
+
// here keeps one implementation of the timing, config, and mutual exclusion.
|
|
38
|
+
Worktree.setSweepRequester(requestScopeSweep)
|
|
39
|
+
if (!scope.local || instanceState.schedules.has(scope.id)) return
|
|
40
|
+
const config = await readWorktreeConfig().catch(() => undefined)
|
|
41
|
+
if (config?.janitor === false) {
|
|
42
|
+
log.info("worktree janitor disabled by config", { scopeID: scope.id })
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
const intervalMs = (config?.sweepIntervalHours ?? DEFAULT_SWEEP_INTERVAL_HOURS) * 60 * 60 * 1000
|
|
46
|
+
const trigger = () => requestScopeSweep(scope)
|
|
47
|
+
const first = setTimeout(trigger, 0)
|
|
48
|
+
first.unref()
|
|
49
|
+
const interval = setInterval(trigger, intervalMs)
|
|
50
|
+
interval.unref()
|
|
51
|
+
instanceState.schedules.set(scope.id, { first, interval })
|
|
52
|
+
log.info("worktree janitor scheduled", { scopeID: scope.id, intervalMs })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function stopWorktreeJanitor(scopeID: string) {
|
|
56
|
+
const instanceState = runtimeState()
|
|
57
|
+
|
|
58
|
+
const schedule = instanceState.schedules.get(scopeID)
|
|
59
|
+
if (schedule) {
|
|
60
|
+
clearTimeout(schedule.first)
|
|
61
|
+
clearInterval(schedule.interval)
|
|
62
|
+
}
|
|
63
|
+
instanceState.schedules.delete(scopeID)
|
|
64
|
+
instanceState.rerunRequested.delete(scopeID)
|
|
65
|
+
await instanceState.sweeping.get(scopeID)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Schedule a cap sweep for a scope without awaiting it.
|
|
70
|
+
*
|
|
71
|
+
* Called after a worktree is created, which is when the cap is most likely to be
|
|
72
|
+
* exceeded. It must never throw or block: creation has already succeeded, so a
|
|
73
|
+
* sweep that cannot converge must not surface as a creation failure. If a sweep
|
|
74
|
+
* is already running, one follow-up is queued rather than dropped — the running
|
|
75
|
+
* sweep may have read its inventory before this worktree existed.
|
|
76
|
+
*/
|
|
77
|
+
export function requestScopeSweep(scope: Scope.Project) {
|
|
78
|
+
const instanceState = runtimeState()
|
|
79
|
+
|
|
80
|
+
// Only a scope whose janitor actually started may sweep. The requester is
|
|
81
|
+
// installed process-wide by the first scope that starts one, so without this
|
|
82
|
+
// guard a creation in any other scope would run a background sweep it never
|
|
83
|
+
// opted into — cancelling registrations that scope is still using.
|
|
84
|
+
if (!scope.local || !instanceState.schedules.has(scope.id)) return
|
|
85
|
+
if (instanceState.sweeping.has(scope.id)) {
|
|
86
|
+
instanceState.rerunRequested.add(scope.id)
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
void sweep(scope)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function sweep(scope: Scope.Project): Promise<void> {
|
|
93
|
+
const instanceState = runtimeState()
|
|
94
|
+
|
|
95
|
+
const current = instanceState.sweeping.get(scope.id)
|
|
96
|
+
if (current) return current
|
|
97
|
+
if (!instanceState.schedules.has(scope.id)) return Promise.resolve()
|
|
98
|
+
const task = runSweep(scope).finally(() => {
|
|
99
|
+
const instanceState = runtimeState()
|
|
100
|
+
|
|
101
|
+
if (instanceState.sweeping.get(scope.id) === task) instanceState.sweeping.delete(scope.id)
|
|
102
|
+
if (instanceState.rerunRequested.delete(scope.id) && instanceState.schedules.has(scope.id)) void sweep(scope)
|
|
103
|
+
})
|
|
104
|
+
instanceState.sweeping.set(scope.id, task)
|
|
105
|
+
return task
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function runSweep(scope: Scope.Project) {
|
|
109
|
+
const instanceState = runtimeState()
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
// Resolved per sweep so a config reload takes effect without a restart, and
|
|
113
|
+
// so a read failure falls back to the cap default rather than skipping.
|
|
114
|
+
const config = await readWorktreeConfig().catch(() => undefined)
|
|
115
|
+
if (config?.janitor === false || !instanceState.schedules.has(scope.id)) return
|
|
116
|
+
const report = await ScopeContext.provide({
|
|
117
|
+
scope,
|
|
118
|
+
workspace: null,
|
|
119
|
+
fn: async () => {
|
|
120
|
+
const { ids } = await ProjectWorktrees.sources(scope.id)
|
|
121
|
+
const results = await Promise.allSettled(
|
|
122
|
+
ids.map((id) => Worktree.withSource(id, () => Worktree.sweep({ maxManaged: config?.maxManaged }))),
|
|
123
|
+
)
|
|
124
|
+
const reports = results.flatMap((result, index) => {
|
|
125
|
+
if (result.status === "fulfilled") return [result.value]
|
|
126
|
+
log.warn("Worktree source sweep failed", { scopeID: scope.id, workspaceID: ids[index], error: result.reason })
|
|
127
|
+
return []
|
|
128
|
+
})
|
|
129
|
+
return {
|
|
130
|
+
scanned: reports.reduce((total, item) => total + item.scanned, 0),
|
|
131
|
+
maxManaged: config?.maxManaged,
|
|
132
|
+
removed: reports.flatMap((item) => item.removed),
|
|
133
|
+
reconciled: reports.flatMap((item) => item.reconciled),
|
|
134
|
+
skipped: reports.flatMap((item) => item.skipped),
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
})
|
|
138
|
+
// Reasons are reported rather than swallowed: a cap that cannot converge is
|
|
139
|
+
// the signal that worktrees are blocked on unpushed work, not a silent
|
|
140
|
+
// pile-up.
|
|
141
|
+
const blocked = report.skipped.map((item) => `${item.name}:${item.reason}`).join(", ")
|
|
142
|
+
log.info("worktree sweep complete", {
|
|
143
|
+
scopeID: scope.id,
|
|
144
|
+
scanned: report.scanned,
|
|
145
|
+
maxManaged: report.maxManaged,
|
|
146
|
+
removed: report.removed.length,
|
|
147
|
+
reconciled: report.reconciled.length,
|
|
148
|
+
blocked: blocked || "none",
|
|
149
|
+
})
|
|
150
|
+
} catch (error) {
|
|
151
|
+
log.warn("worktree sweep failed", { scopeID: scope.id, error })
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { WorkspaceCatalog } from "@ericsanchezok/synergy-harness/workspace"
|
|
2
|
+
import { Worktree } from "@ericsanchezok/synergy-local-runtime/workspace/worktree"
|
|
3
|
+
import { Log } from "@ericsanchezok/synergy-harness/util/log"
|
|
4
|
+
import { ProjectDirectories } from "./directories"
|
|
5
|
+
import { RuntimeContext } from "@ericsanchezok/synergy-harness/lifecycle/context"
|
|
6
|
+
import { z } from "zod"
|
|
7
|
+
import { Bus } from "@ericsanchezok/synergy-harness/bus"
|
|
8
|
+
|
|
9
|
+
export namespace ProjectWorktrees {
|
|
10
|
+
const log = Log.create({ service: "project-worktrees" })
|
|
11
|
+
export const Inventory = z
|
|
12
|
+
.object({
|
|
13
|
+
items: Worktree.InventoryEntry.array(),
|
|
14
|
+
version: z.string(),
|
|
15
|
+
generatedAt: z.number(),
|
|
16
|
+
sync: z.object({ epoch: z.string(), seq: z.number().int().nonnegative() }),
|
|
17
|
+
})
|
|
18
|
+
.meta({ ref: "ProjectWorktreeInventory" })
|
|
19
|
+
export type Inventory = z.infer<typeof Inventory>
|
|
20
|
+
const inventoryCache = RuntimeContext.state(
|
|
21
|
+
() => new Map<string, { version: string; expires: number; pending: Promise<Inventory> }>(),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
export async function sources(scopeID: string) {
|
|
25
|
+
const directories = await ProjectDirectories.get(scopeID)
|
|
26
|
+
const records = await WorkspaceCatalog.list(scopeID)
|
|
27
|
+
const ids = new Set(
|
|
28
|
+
directories.folders.filter((folder) => folder.available && folder.git).map((folder) => folder.workspaceID),
|
|
29
|
+
)
|
|
30
|
+
for (const tree of records) {
|
|
31
|
+
if (tree.type !== "git_worktree") continue
|
|
32
|
+
const source = records.find(
|
|
33
|
+
(item) => item.id === tree.metadata.sourceWorkspaceID || item.binding.path === tree.metadata.originalCheckout,
|
|
34
|
+
)
|
|
35
|
+
if (source) ids.add(source.id)
|
|
36
|
+
}
|
|
37
|
+
return { ids: [...ids], records, revision: directories.revision }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function list(scopeID: string): Promise<Worktree.Info[]> {
|
|
41
|
+
return collect(scopeID, () => Worktree.list())
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function inventory(scopeID: string): Promise<Inventory> {
|
|
45
|
+
const sync = { epoch: Bus.epoch(), seq: Bus.currentSeq() }
|
|
46
|
+
const current = await sources(scopeID)
|
|
47
|
+
const version = new Bun.CryptoHasher("sha256")
|
|
48
|
+
.update(
|
|
49
|
+
JSON.stringify([
|
|
50
|
+
current.revision,
|
|
51
|
+
current.records.map((record) => [record.id, record.revision, record.binding.generation]),
|
|
52
|
+
]),
|
|
53
|
+
)
|
|
54
|
+
.digest("hex")
|
|
55
|
+
const cache = inventoryCache()
|
|
56
|
+
const cached = cache.get(scopeID)
|
|
57
|
+
if (cached && cached.version === version && cached.expires > Date.now()) return cached.pending
|
|
58
|
+
const pending = collect(scopeID, () => Worktree.inventory(), current).then((items) => ({
|
|
59
|
+
items: items.map((item) => Worktree.InventoryEntry.parse(item)),
|
|
60
|
+
version,
|
|
61
|
+
generatedAt: Date.now(),
|
|
62
|
+
sync,
|
|
63
|
+
}))
|
|
64
|
+
const entry = { version, expires: Date.now() + 5_000, pending }
|
|
65
|
+
cache.set(scopeID, entry)
|
|
66
|
+
void pending.catch(() => {
|
|
67
|
+
if (cache.get(scopeID) === entry) cache.delete(scopeID)
|
|
68
|
+
})
|
|
69
|
+
return pending
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function collect(
|
|
73
|
+
scopeID: string,
|
|
74
|
+
read: () => Promise<Worktree.Info[]>,
|
|
75
|
+
source?: Awaited<ReturnType<typeof sources>>,
|
|
76
|
+
): Promise<Worktree.Info[]> {
|
|
77
|
+
const { ids, records } = source ?? (await sources(scopeID))
|
|
78
|
+
const results = await Promise.allSettled(ids.map((id) => Worktree.withSource(id, read)))
|
|
79
|
+
const trees = new Map<string, Worktree.Info>()
|
|
80
|
+
for (const [index, result] of results.entries()) {
|
|
81
|
+
if (result.status === "rejected") {
|
|
82
|
+
log.warn("Worktree source unavailable", { scopeID, workspaceID: ids[index], error: result.reason })
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
for (const tree of result.value) if (!tree.isMain) trees.set(tree.path, tree)
|
|
86
|
+
}
|
|
87
|
+
for (const record of records) {
|
|
88
|
+
if (
|
|
89
|
+
record.type !== "git_worktree" ||
|
|
90
|
+
record.lifecycle === "deleted" ||
|
|
91
|
+
!record.binding.path ||
|
|
92
|
+
trees.has(record.binding.path)
|
|
93
|
+
)
|
|
94
|
+
continue
|
|
95
|
+
trees.set(record.binding.path, {
|
|
96
|
+
id: String(record.metadata.worktreeID ?? record.id),
|
|
97
|
+
scopeID,
|
|
98
|
+
path: record.binding.path,
|
|
99
|
+
name: String(record.metadata.name ?? record.binding.path.split("/").at(-1)),
|
|
100
|
+
branch: typeof record.metadata.branch === "string" ? record.metadata.branch : undefined,
|
|
101
|
+
sourceWorkspaceID:
|
|
102
|
+
typeof record.metadata.sourceWorkspaceID === "string" ? record.metadata.sourceWorkspaceID : undefined,
|
|
103
|
+
sourceDirectory:
|
|
104
|
+
typeof record.metadata.originalCheckout === "string" ? record.metadata.originalCheckout : undefined,
|
|
105
|
+
stale: true,
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
return [...trees.values()]
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { RuntimeContext } from "@ericsanchezok/synergy-harness/lifecycle/context"
|
|
2
|
+
import { base64Encode } from "@ericsanchezok/synergy-util/encode"
|
|
3
|
+
import { Bus } from "@ericsanchezok/synergy-harness/bus"
|
|
4
|
+
import { Log } from "@ericsanchezok/synergy-harness/util/log"
|
|
5
|
+
import { PermissionNext } from "@ericsanchezok/synergy-harness/permission/next"
|
|
6
|
+
import { Question } from "@ericsanchezok/synergy-local-runtime/question"
|
|
7
|
+
import { ScopeContext } from "@ericsanchezok/synergy-harness/scope/context"
|
|
8
|
+
import { SessionEvent } from "@ericsanchezok/synergy-harness/session/event"
|
|
9
|
+
import { SessionManager } from "@ericsanchezok/synergy-harness/session/manager"
|
|
10
|
+
import type { Info as SessionInfo } from "@ericsanchezok/synergy-harness/session/types"
|
|
11
|
+
import { PushService } from "./service"
|
|
12
|
+
import type { PushTypes } from "./types"
|
|
13
|
+
|
|
14
|
+
export namespace PushBridge {
|
|
15
|
+
const log = Log.create({ service: "push.bridge" })
|
|
16
|
+
|
|
17
|
+
const MAX_BODY_LENGTH = 200
|
|
18
|
+
|
|
19
|
+
const runtimeState = RuntimeContext.state(() => ({
|
|
20
|
+
inflight: new Set<Promise<unknown>>(),
|
|
21
|
+
disposeActive: undefined as (() => void) | undefined,
|
|
22
|
+
}))
|
|
23
|
+
|
|
24
|
+
/** Await all in-flight fan-outs (deterministic tests, graceful shutdown). */
|
|
25
|
+
export async function flush(): Promise<void> {
|
|
26
|
+
const instanceState = runtimeState()
|
|
27
|
+
|
|
28
|
+
while (instanceState.inflight.size > 0) await Promise.allSettled([...instanceState.inflight])
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function init(): () => void {
|
|
32
|
+
const instanceState = runtimeState()
|
|
33
|
+
|
|
34
|
+
if (instanceState.disposeActive) return instanceState.disposeActive
|
|
35
|
+
|
|
36
|
+
const publish = (payload: PushTypes.Payload) => {
|
|
37
|
+
const instanceState = runtimeState()
|
|
38
|
+
|
|
39
|
+
const task = PushService.send(payload).catch((error) => log.warn("push send failed", { error }))
|
|
40
|
+
instanceState.inflight.add(task)
|
|
41
|
+
void task.finally(() => {
|
|
42
|
+
const instanceState = runtimeState()
|
|
43
|
+
return instanceState.inflight.delete(task)
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const truncate = (body: string): string =>
|
|
48
|
+
body.length > MAX_BODY_LENGTH ? body.slice(0, MAX_BODY_LENGTH - 1) + "…" : body
|
|
49
|
+
|
|
50
|
+
// Mirrors the Web app's notification-event.ts href contract: the scope
|
|
51
|
+
// token is "home" for the home scope, otherwise the scope directory.
|
|
52
|
+
const scopeToken = (session: SessionInfo): string =>
|
|
53
|
+
session.scope.type === "home" ? "home" : (session.scope.local?.directory ?? "home")
|
|
54
|
+
|
|
55
|
+
const sessionHref = (session: SessionInfo): string => `/${base64Encode(scopeToken(session))}/session/${session.id}`
|
|
56
|
+
|
|
57
|
+
const resolveSession = async (sessionID: string | undefined): Promise<SessionInfo | undefined> => {
|
|
58
|
+
if (!sessionID) return undefined
|
|
59
|
+
return SessionManager.getSession(sessionID).catch(() => undefined)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Channel-endpoint sessions deliver through their own channel surface
|
|
63
|
+
// (outbound bridge / question cards); completion/error additionally skip
|
|
64
|
+
// child sessions to mirror the in-app notification rules.
|
|
65
|
+
const skip = (session: SessionInfo, category: "completion" | "error" | "input"): boolean => {
|
|
66
|
+
if (session.endpoint?.kind === "channel") return true
|
|
67
|
+
if (category !== "input" && session.parentID) return true
|
|
68
|
+
return false
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const unsubscribers = [
|
|
72
|
+
Bus.subscribeGlobal(SessionEvent.Completion, (event) => {
|
|
73
|
+
const instanceState = runtimeState()
|
|
74
|
+
|
|
75
|
+
const task = (async () => {
|
|
76
|
+
const session = await resolveSession(event.properties.sessionID)
|
|
77
|
+
if (!session || skip(session, "completion")) return
|
|
78
|
+
publish({
|
|
79
|
+
title: "Response ready",
|
|
80
|
+
body: truncate(session.title),
|
|
81
|
+
href: sessionHref(session),
|
|
82
|
+
tag: `session-${session.id}`,
|
|
83
|
+
category: "completion",
|
|
84
|
+
badge: event.properties.unreadCount,
|
|
85
|
+
})
|
|
86
|
+
})().catch((error) => log.warn("completion push failed", { error }))
|
|
87
|
+
instanceState.inflight.add(task)
|
|
88
|
+
void task.finally(() => {
|
|
89
|
+
const instanceState = runtimeState()
|
|
90
|
+
return instanceState.inflight.delete(task)
|
|
91
|
+
})
|
|
92
|
+
}),
|
|
93
|
+
Bus.subscribeGlobal(SessionEvent.Error, (event) => {
|
|
94
|
+
const instanceState = runtimeState()
|
|
95
|
+
|
|
96
|
+
const task = (async () => {
|
|
97
|
+
const sessionID = event.properties.sessionID
|
|
98
|
+
if (!sessionID) {
|
|
99
|
+
// Truly global errors (no session context) fall back to the
|
|
100
|
+
// current scope root; unresolved session-scoped errors stay
|
|
101
|
+
// silent instead of leaking raw error text as a global push.
|
|
102
|
+
const scope = ScopeContext.current.scope
|
|
103
|
+
const directory = scope.type === "home" ? "home" : (scope.local?.directory ?? "home")
|
|
104
|
+
publish({
|
|
105
|
+
title: "Session error",
|
|
106
|
+
body: truncate(typeof event.properties.error === "string" ? event.properties.error : "Session error"),
|
|
107
|
+
href: `/${base64Encode(directory)}`,
|
|
108
|
+
tag: "session-global",
|
|
109
|
+
category: "error",
|
|
110
|
+
})
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
const session = await resolveSession(sessionID)
|
|
114
|
+
if (!session || skip(session, "error")) return
|
|
115
|
+
const error = event.properties.error
|
|
116
|
+
publish({
|
|
117
|
+
title: "Session error",
|
|
118
|
+
body: truncate(session.title ?? (typeof error === "string" ? error : "Session error")),
|
|
119
|
+
href: sessionHref(session),
|
|
120
|
+
tag: `session-${session.id}`,
|
|
121
|
+
category: "error",
|
|
122
|
+
})
|
|
123
|
+
})().catch((error) => log.warn("error push failed", { error }))
|
|
124
|
+
instanceState.inflight.add(task)
|
|
125
|
+
void task.finally(() => {
|
|
126
|
+
const instanceState = runtimeState()
|
|
127
|
+
return instanceState.inflight.delete(task)
|
|
128
|
+
})
|
|
129
|
+
}),
|
|
130
|
+
Bus.subscribeGlobal(Question.Event.Asked, (event) => {
|
|
131
|
+
const instanceState = runtimeState()
|
|
132
|
+
|
|
133
|
+
const task = (async () => {
|
|
134
|
+
const session = await resolveSession(event.properties.sessionID)
|
|
135
|
+
if (!session || skip(session, "input")) return
|
|
136
|
+
publish({
|
|
137
|
+
title: "Session needs your input",
|
|
138
|
+
body: truncate(session.title),
|
|
139
|
+
href: sessionHref(session),
|
|
140
|
+
tag: `input-${session.id}`,
|
|
141
|
+
category: "input",
|
|
142
|
+
})
|
|
143
|
+
})().catch((error) => log.warn("question push failed", { error }))
|
|
144
|
+
instanceState.inflight.add(task)
|
|
145
|
+
void task.finally(() => {
|
|
146
|
+
const instanceState = runtimeState()
|
|
147
|
+
return instanceState.inflight.delete(task)
|
|
148
|
+
})
|
|
149
|
+
}),
|
|
150
|
+
Bus.subscribeGlobal(PermissionNext.Event.Asked, (event) => {
|
|
151
|
+
const instanceState = runtimeState()
|
|
152
|
+
|
|
153
|
+
const task = (async () => {
|
|
154
|
+
const session = await resolveSession(event.properties.sessionID)
|
|
155
|
+
if (!session || skip(session, "input")) return
|
|
156
|
+
publish({
|
|
157
|
+
title: "Session needs your input",
|
|
158
|
+
body: truncate(session.title),
|
|
159
|
+
href: sessionHref(session),
|
|
160
|
+
tag: `input-${session.id}`,
|
|
161
|
+
category: "input",
|
|
162
|
+
})
|
|
163
|
+
})().catch((error) => log.warn("permission push failed", { error }))
|
|
164
|
+
instanceState.inflight.add(task)
|
|
165
|
+
void task.finally(() => {
|
|
166
|
+
const instanceState = runtimeState()
|
|
167
|
+
return instanceState.inflight.delete(task)
|
|
168
|
+
})
|
|
169
|
+
}),
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
const dispose = () => {
|
|
173
|
+
const instanceState = runtimeState()
|
|
174
|
+
|
|
175
|
+
if (instanceState.disposeActive !== dispose) return
|
|
176
|
+
for (const unsubscribe of unsubscribers) unsubscribe()
|
|
177
|
+
instanceState.disposeActive = undefined
|
|
178
|
+
}
|
|
179
|
+
instanceState.disposeActive = dispose
|
|
180
|
+
return dispose
|
|
181
|
+
}
|
|
182
|
+
}
|