@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.
Files changed (153) hide show
  1. package/AGENTS.md +30 -0
  2. package/README.md +16 -2
  3. package/dist/agents.js +11 -0
  4. package/dist/cli-adapter.js +8 -0
  5. package/dist/component.js +65 -0
  6. package/dist/config-schema.js +265 -0
  7. package/dist/execution/activity.js +78 -0
  8. package/dist/execution/content.js +184 -0
  9. package/dist/execution/json-sections.js +231 -0
  10. package/dist/execution/presentation.js +79 -0
  11. package/dist/execution/preview.js +13 -0
  12. package/dist/execution/process.js +125 -0
  13. package/dist/execution/projection.js +327 -0
  14. package/dist/execution/query.js +18 -0
  15. package/dist/execution/routes.js +189 -0
  16. package/dist/execution/schema.js +322 -0
  17. package/dist/execution/service.js +1993 -0
  18. package/dist/features.js +22 -0
  19. package/dist/http.js +65 -0
  20. package/dist/performance/analysis.js +330 -0
  21. package/dist/performance/catalog.js +391 -0
  22. package/dist/performance/dashboard.js +483 -0
  23. package/dist/performance/error.js +15 -0
  24. package/dist/performance/index.js +6 -0
  25. package/dist/performance/inflight.js +43 -0
  26. package/dist/performance/projection.js +138 -0
  27. package/dist/performance/prompt/base.txt +38 -0
  28. package/dist/performance/prompt/builder.js +21 -0
  29. package/dist/performance/routes/performance-route.js +439 -0
  30. package/dist/performance/schema.js +628 -0
  31. package/dist/performance/timeline.js +98 -0
  32. package/dist/performance/trace-detail.js +91 -0
  33. package/dist/presentation/service.js +270 -0
  34. package/dist/presentation/tools/workbench-view.js +65 -0
  35. package/dist/presentation/tools.js +10 -0
  36. package/dist/project/directories.js +363 -0
  37. package/dist/project/git-health.js +498 -0
  38. package/dist/project/ide.js +66 -0
  39. package/dist/project/migration.js +22 -0
  40. package/dist/project/routes/directories.js +89 -0
  41. package/dist/project/routes/git.js +30 -0
  42. package/dist/project/routes/task-defaults.js +42 -0
  43. package/dist/project/session-health.js +9 -0
  44. package/dist/project/startup.js +28 -0
  45. package/dist/project/task-defaults.js +68 -0
  46. package/dist/project/tools/session-control.js +664 -0
  47. package/dist/project/tools/session-control.txt +60 -0
  48. package/dist/project/tools/worktree-archive.js +76 -0
  49. package/dist/project/tools/worktree-enter.js +175 -0
  50. package/dist/project/tools/worktree-leave.js +119 -0
  51. package/dist/project/tools/worktree-list.js +52 -0
  52. package/dist/project/tools.js +26 -0
  53. package/dist/project/vcs-branch-watcher.js +76 -0
  54. package/dist/project/vcs.js +114 -0
  55. package/dist/project/worktree-janitor.js +140 -0
  56. package/dist/project/worktrees.js +98 -0
  57. package/dist/push/bridge.js +171 -0
  58. package/dist/push/index.js +4 -0
  59. package/dist/push/routes/push.js +224 -0
  60. package/dist/push/service.js +93 -0
  61. package/dist/push/store.js +121 -0
  62. package/dist/push/types.js +58 -0
  63. package/dist/review/git.js +226 -0
  64. package/dist/review/routes.js +45 -0
  65. package/dist/review/schema.js +32 -0
  66. package/dist/review/state.js +79 -0
  67. package/dist/stats/aggregator.js +252 -0
  68. package/dist/stats/canonical.js +89 -0
  69. package/dist/stats/cli/stats.js +302 -0
  70. package/dist/stats/engine.js +176 -0
  71. package/dist/stats/index.js +5 -0
  72. package/dist/stats/rollup.js +460 -0
  73. package/dist/stats/routes/stats.js +94 -0
  74. package/dist/stats/routes/usage.js +83 -0
  75. package/dist/stats/storage.js +86 -0
  76. package/dist/stats/types.js +267 -0
  77. package/dist/worker.js +6 -0
  78. package/package.json +232 -4
  79. package/src/agents.ts +15 -0
  80. package/src/cli-adapter.ts +11 -0
  81. package/src/component.ts +73 -0
  82. package/src/config-schema.ts +289 -0
  83. package/src/execution/activity.ts +83 -0
  84. package/src/execution/content.ts +189 -0
  85. package/src/execution/json-sections.ts +230 -0
  86. package/src/execution/presentation.ts +91 -0
  87. package/src/execution/preview.ts +12 -0
  88. package/src/execution/process.ts +118 -0
  89. package/src/execution/projection.ts +330 -0
  90. package/src/execution/query.ts +19 -0
  91. package/src/execution/routes.ts +324 -0
  92. package/src/execution/schema.ts +336 -0
  93. package/src/execution/service.ts +2149 -0
  94. package/src/features.ts +22 -0
  95. package/src/http.ts +75 -0
  96. package/src/performance/analysis.ts +356 -0
  97. package/src/performance/catalog.ts +902 -0
  98. package/src/performance/dashboard.ts +521 -0
  99. package/src/performance/error.ts +32 -0
  100. package/src/performance/index.ts +6 -0
  101. package/src/performance/inflight.ts +46 -0
  102. package/src/performance/projection.ts +143 -0
  103. package/src/performance/prompt/base.txt +38 -0
  104. package/src/performance/prompt/builder.ts +24 -0
  105. package/src/performance/routes/performance-route.ts +541 -0
  106. package/src/performance/schema.ts +695 -0
  107. package/src/performance/timeline.ts +100 -0
  108. package/src/performance/trace-detail.ts +97 -0
  109. package/src/presentation/service.ts +303 -0
  110. package/src/presentation/tools/workbench-view.ts +75 -0
  111. package/src/presentation/tools.ts +11 -0
  112. package/src/project/directories.ts +391 -0
  113. package/src/project/git-health.ts +577 -0
  114. package/src/project/ide.ts +80 -0
  115. package/src/project/migration.ts +24 -0
  116. package/src/project/routes/directories.ts +124 -0
  117. package/src/project/routes/git.ts +42 -0
  118. package/src/project/routes/task-defaults.ts +50 -0
  119. package/src/project/session-health.ts +10 -0
  120. package/src/project/startup.ts +28 -0
  121. package/src/project/task-defaults.ts +70 -0
  122. package/src/project/tools/session-control.ts +725 -0
  123. package/src/project/tools/session-control.txt +60 -0
  124. package/src/project/tools/worktree-archive.ts +92 -0
  125. package/src/project/tools/worktree-enter.ts +227 -0
  126. package/src/project/tools/worktree-leave.ts +140 -0
  127. package/src/project/tools/worktree-list.ts +68 -0
  128. package/src/project/tools.ts +29 -0
  129. package/src/project/vcs-branch-watcher.ts +81 -0
  130. package/src/project/vcs.ts +124 -0
  131. package/src/project/worktree-janitor.ts +153 -0
  132. package/src/project/worktrees.ts +110 -0
  133. package/src/push/bridge.ts +182 -0
  134. package/src/push/index.ts +4 -0
  135. package/src/push/routes/push.ts +267 -0
  136. package/src/push/service.ts +108 -0
  137. package/src/push/store.ts +120 -0
  138. package/src/push/types.ts +67 -0
  139. package/src/review/git.ts +221 -0
  140. package/src/review/routes.ts +65 -0
  141. package/src/review/schema.ts +34 -0
  142. package/src/review/state.ts +77 -0
  143. package/src/stats/aggregator.ts +277 -0
  144. package/src/stats/canonical.ts +93 -0
  145. package/src/stats/cli/stats.ts +348 -0
  146. package/src/stats/engine.ts +197 -0
  147. package/src/stats/index.ts +5 -0
  148. package/src/stats/rollup.ts +522 -0
  149. package/src/stats/routes/stats.ts +107 -0
  150. package/src/stats/routes/usage.ts +103 -0
  151. package/src/stats/storage.ts +78 -0
  152. package/src/stats/types.ts +351 -0
  153. 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
+ }
@@ -0,0 +1,4 @@
1
+ export { PushTypes } from "./types"
2
+ export { PushStore } from "./store"
3
+ export { PushService } from "./service"
4
+ export { PushBridge } from "./bridge"