@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,100 @@
1
+ import { ObservabilityStore } from "@ericsanchezok/synergy-harness/observability/store"
2
+ import { PerformanceCatalog } from "./catalog"
3
+ import { ObservabilityConfig } from "@ericsanchezok/synergy-harness/observability/config"
4
+ import { PerformanceError } from "./error"
5
+ import { PerformanceSchema } from "./schema"
6
+
7
+ export namespace PerformanceTimeline {
8
+ export async function get(query: PerformanceSchema.TimelineQuery): Promise<PerformanceSchema.Timeline> {
9
+ const now = Date.now()
10
+ const to = parseTime(query.to, now)
11
+ const from = parseTime(query.from, query.windowMs ? to - query.windowMs : to - 15 * 60 * 1000)
12
+ if (from >= to) throw new PerformanceError("PERF_INVALID_QUERY", "Timeline from must be before to.", 400)
13
+
14
+ const config = ObservabilityConfig.current()
15
+ const bucketMs = query.bucketMs ?? Math.max(1000, Math.floor((to - from) / config.maxTimelineBuckets) + 1)
16
+ const bucketCount = Math.floor((to - from) / bucketMs) + 1
17
+ if (bucketCount > config.maxTimelineBuckets) {
18
+ throw new PerformanceError("PERF_TOO_MANY_BUCKETS", "Timeline query exceeds the configured bucket limit.", 400, {
19
+ maxBuckets: config.maxTimelineBuckets,
20
+ bucketCount,
21
+ })
22
+ }
23
+
24
+ const metrics = normalizeMetrics(query.metric)
25
+ const buckets = bucketStarts(from, to, bucketMs)
26
+ const series = await Promise.all(
27
+ metrics.map(async (name) => {
28
+ const info = PerformanceCatalog.get(name)
29
+ if (!info) throw new PerformanceError("PERF_INVALID_QUERY", "Timeline metric is not allowed.", 400)
30
+ const stat = query.stat ?? info.defaultStat
31
+ const rows = await ObservabilityStore.queryMetricBuckets({
32
+ since: from,
33
+ until: to,
34
+ names: [name],
35
+ module: query.module,
36
+ scopeID: query.scopeID,
37
+ sessionID: query.sessionID,
38
+ tool: query.tool,
39
+ providerID: query.providerID,
40
+ bucketMs,
41
+ })
42
+ const bucketed = new Map(rows.map((row) => [row.bucket, row]))
43
+ const points = buckets.map((time, index) => {
44
+ const bucket = bucketed.get(index)
45
+ return {
46
+ time,
47
+ value: bucket ? (stat === "rate" ? bucket.sum / Math.max(1, bucketMs / 1000) : bucket[stat]) : null,
48
+ sampleCount: bucket?.count ?? 0,
49
+ }
50
+ })
51
+ const sampleCount = points.reduce((total, point) => total + (point.sampleCount ?? 0), 0)
52
+ return PerformanceSchema.TimelineSeries.parse({
53
+ name,
54
+ label: info.label,
55
+ unit: info.unit,
56
+ kind: info.kind,
57
+ stat,
58
+ sampleCount,
59
+ module: info.module,
60
+ source: info.source,
61
+ points,
62
+ })
63
+ }),
64
+ )
65
+ return PerformanceSchema.Timeline.parse({
66
+ generatedAt: new Date().toISOString(),
67
+ from,
68
+ to,
69
+ bucketMs,
70
+ series,
71
+ })
72
+ }
73
+
74
+ function parseTime(value: string | undefined, fallback: number) {
75
+ if (!value) return fallback
76
+ const parsed = Date.parse(value)
77
+ if (!Number.isFinite(parsed)) throw new PerformanceError("PERF_INVALID_QUERY", "Invalid timeline time range.", 400)
78
+ return parsed
79
+ }
80
+
81
+ function normalizeMetrics(metric: string | string[] | undefined) {
82
+ const requested = Array.isArray(metric) ? metric : metric ? [metric] : PerformanceCatalog.defaultMetricNames
83
+ const resolved = requested.map((name) => PerformanceCatalog.resolveName(name))
84
+ const unique = [...new Set(resolved)]
85
+ const invalid = unique.filter((name) => !PerformanceCatalog.get(name))
86
+ if (invalid.length)
87
+ throw new PerformanceError("PERF_INVALID_QUERY", "Timeline metric is not allowed.", 400, {
88
+ invalidMetrics: invalid,
89
+ })
90
+ return unique
91
+ }
92
+
93
+ function bucketStarts(from: number, to: number, bucketMs: number) {
94
+ const buckets: number[] = []
95
+ for (let bucket = from; bucket <= to; bucket += bucketMs) buckets.push(bucket)
96
+ return buckets
97
+ }
98
+
99
+ export const allowedMetricNames = PerformanceCatalog.allMetricNames()
100
+ }
@@ -0,0 +1,97 @@
1
+ import { Observability } from "@ericsanchezok/synergy-harness/observability"
2
+ import { ObservabilityStore } from "@ericsanchezok/synergy-harness/observability/store"
3
+ import { PerformanceError } from "./error"
4
+ import { PerformanceProjection } from "./projection"
5
+ import { PerformanceSchema } from "./schema"
6
+ import { parseJson } from "@ericsanchezok/synergy-harness/util/json-parse"
7
+
8
+ export namespace PerformanceTraceDetail {
9
+ export function list(query: PerformanceSchema.TraceListQuery): PerformanceSchema.TraceList {
10
+ const since = query.from ? Date.parse(query.from) : Date.now() - 15 * 60 * 1000
11
+ const until = query.to ? Date.parse(query.to) : undefined
12
+ const baseLimit = query.limit ?? 50
13
+ const rows = ObservabilityStore.querySpans({
14
+ since: Number.isFinite(since) ? since : undefined,
15
+ until: until !== undefined && Number.isFinite(until) ? until : undefined,
16
+ limit: baseLimit,
17
+ minDurationMs: query.minDurationMs,
18
+ status: query.status,
19
+ scopeID: query.scopeID,
20
+ sessionID: query.sessionID,
21
+ kinds: query.kind ? PerformanceProjection.spanKinds(query.kind) : undefined,
22
+ distinctTrace: true,
23
+ })
24
+ return PerformanceSchema.TraceList.parse({
25
+ generatedAt: new Date().toISOString(),
26
+ items: rows.map(PerformanceProjection.traceRow),
27
+ })
28
+ }
29
+
30
+ export async function detail(
31
+ traceId: string,
32
+ opts: { maxEvents?: number; includeEvents?: boolean; includeAttributes?: boolean } = {},
33
+ ): Promise<PerformanceSchema.TraceDetail> {
34
+ const spans = ObservabilityStore.querySpans({ traceId, limit: 10_000 }).sort((a, b) => a.start_time - b.start_time)
35
+ const events =
36
+ opts.includeEvents === false
37
+ ? []
38
+ : (await Observability.query({ traceId, limit: opts.maxEvents ?? 500 })).map(projectEvent)
39
+ if (!spans.length && !events.length)
40
+ throw new PerformanceError("PERF_TRACE_NOT_FOUND", "Performance trace was not found.", 404, { traceId })
41
+ const parsed = spans.map((row) =>
42
+ PerformanceSchema.Span.parse({
43
+ traceId: row.trace_id,
44
+ correlationId: row.correlation_id ?? undefined,
45
+ spanId: row.span_id,
46
+ parentSpanId: row.parent_span_id ?? undefined,
47
+ kind: row.kind,
48
+ name: row.name,
49
+ module: row.module,
50
+ source: row.source,
51
+ startTime: row.start_time,
52
+ endTime: row.end_time ?? undefined,
53
+ durationMs: row.duration_ms ?? undefined,
54
+ status: row.status,
55
+ lastActivityTime: row.last_activity_time ?? undefined,
56
+ heartbeatTime: row.heartbeat_time ?? undefined,
57
+ heartbeatCount: row.heartbeat_count ?? undefined,
58
+ stalled: row.stalled ? true : undefined,
59
+ errorCode: row.error_code ?? undefined,
60
+ errorMessage: row.error_message ?? undefined,
61
+ sessionID: row.session_id ?? undefined,
62
+ scopeID: row.scope_id ?? undefined,
63
+ rid: row.rid ?? undefined,
64
+ tool: row.tool ?? undefined,
65
+ attributes: opts.includeAttributes === false ? {} : parseJson(row.attributes_json),
66
+ }),
67
+ )
68
+ return PerformanceSchema.TraceDetail.parse({
69
+ generatedAt: new Date().toISOString(),
70
+ traceId,
71
+ root: parsed.find((span) => !span.parentSpanId) ?? parsed[0],
72
+ spans: parsed,
73
+ events,
74
+ redaction: { applied: true, omittedAttributes: opts.includeAttributes === false ? parsed.length : 0 },
75
+ })
76
+ }
77
+
78
+ function projectEvent(event: Observability.Event): PerformanceSchema.TraceEvent {
79
+ return PerformanceSchema.TraceEvent.parse({
80
+ time: event.time,
81
+ iso: event.iso,
82
+ type: event.type,
83
+ level: event.level,
84
+ traceId: event.traceId,
85
+ correlationId: event.correlationId,
86
+ sessionID: event.sessionID,
87
+ messageID: event.messageID,
88
+ callID: event.callID,
89
+ rid: event.rid,
90
+ tool: event.tool,
91
+ processId: event.processId,
92
+ pid: event.pid,
93
+ dataKeys: event.data && typeof event.data === "object" ? Object.keys(event.data).slice(0, 24) : [],
94
+ redactionApplied: event.redaction.applied,
95
+ })
96
+ }
97
+ }
@@ -0,0 +1,303 @@
1
+ import { RuntimeContext } from "@ericsanchezok/synergy-harness/lifecycle/context"
2
+ import { createHash } from "node:crypto"
3
+ import type { Server } from "@ericsanchezok/synergy-server/server/server"
4
+ import { ResourceReference } from "@ericsanchezok/synergy-util/resource-reference"
5
+ import { WorkbenchView } from "@ericsanchezok/synergy-util/workbench-view"
6
+
7
+ export namespace WorkbenchPresentation {
8
+ export const DEFAULT_TIMEOUT_SECONDS = 30
9
+ export type Caller = {
10
+ scopeID: string
11
+ sessionID: string
12
+ requestId: string
13
+ context?: ResourceReference.Context
14
+ signal: AbortSignal
15
+ }
16
+ type Attempt = {
17
+ signature: string
18
+ caller: Pick<Caller, "scopeID" | "sessionID" | "requestId">
19
+ command?: WorkbenchView.Command
20
+ target?: WorkbenchView.Target
21
+ promise: Promise<WorkbenchView.Result>
22
+ result?: WorkbenchView.Result
23
+ receipt: boolean
24
+ settle?: (outcome: WorkbenchView.Outcome) => void
25
+ cancel?: (reason: string) => void
26
+ }
27
+ type Client = {
28
+ clientId: string
29
+ send: (frame: Server.EventClientFrame) => boolean
30
+ targets?: Set<WorkbenchView.TargetKind>
31
+ active?: Server.EventClientActive
32
+ attempts: Map<string, Attempt>
33
+ views: Map<string, WorkbenchView.View>
34
+ }
35
+ const state = RuntimeContext.state(() => ({
36
+ clients: new Map<string, Client>(),
37
+ attempts: new Map<string, Attempt>(),
38
+ disposed: false,
39
+ }))
40
+ const key = (caller: Pick<Caller, "scopeID" | "sessionID" | "requestId">) =>
41
+ JSON.stringify([caller.scopeID, caller.sessionID, caller.requestId])
42
+ const unknown = (reason: string): WorkbenchView.Outcome => ({ status: "unknown", reason })
43
+ function targetIdentity(target: WorkbenchView.Target): string {
44
+ const id =
45
+ target.kind === "note"
46
+ ? target.id
47
+ : target.kind === "workspace-file"
48
+ ? target.path
49
+ : target.kind === "browser-page"
50
+ ? target.pageId
51
+ : target.url
52
+ return JSON.stringify([target.kind, id])
53
+ }
54
+
55
+ export function init(): () => void {
56
+ const instance = state()
57
+ return () => {
58
+ if (instance.disposed) return
59
+ instance.disposed = true
60
+ for (const client of instance.clients.values()) {
61
+ for (const attempt of client.attempts.values())
62
+ attempt.cancel?.("The Runtime is closing; no receipt confirms the effect.")
63
+ client.attempts.clear()
64
+ client.views.clear()
65
+ }
66
+ instance.clients.clear()
67
+ instance.attempts.clear()
68
+ }
69
+ }
70
+
71
+ export function contribution(): Server.EventClientContribution {
72
+ const instance = state()
73
+ return {
74
+ connect({ clientId, send }) {
75
+ const client: Client = { clientId, send, attempts: new Map(), views: new Map() }
76
+ if (!instance.disposed) instance.clients.set(clientId, client)
77
+ let disposed = instance.disposed
78
+ return {
79
+ receive(payload) {
80
+ if (disposed || instance.disposed) return
81
+ const parsed = WorkbenchView.ClientMessage.safeParse(payload)
82
+ if (!parsed.success) return
83
+ const message = parsed.data
84
+ if (message.type === "client.workbench.ready") {
85
+ client.targets = new Set(message.properties.targets)
86
+ for (const attempt of client.attempts.values())
87
+ if (attempt.command?.action === "open" && !client.targets.has(attempt.command.target.kind))
88
+ attempt.cancel?.("The frontend no longer supports this target; no receipt confirms the effect.")
89
+ return
90
+ }
91
+ if (!client.active) return
92
+ const attempt = client.attempts.get(key({ ...client.active, requestId: message.properties.requestId }))
93
+ if (!attempt?.settle) return
94
+ const outcome = message.properties.outcome
95
+ if (outcome.status === "applied") {
96
+ const views = outcome.views ?? []
97
+ if (new Set(views.map((view) => view.viewId)).size !== views.length) return
98
+ const command = attempt.command
99
+ if (!command) return
100
+ const target = attempt.target
101
+ if (
102
+ command.action === "open" &&
103
+ (!target ||
104
+ !views.some((view) => view.visible && targetIdentity(view.target) === targetIdentity(target)))
105
+ )
106
+ return
107
+ if (
108
+ command.action === "hide" &&
109
+ (!target ||
110
+ !views.some(
111
+ (view) =>
112
+ view.viewId === command.viewId &&
113
+ !view.visible &&
114
+ targetIdentity(view.target) === targetIdentity(target),
115
+ ))
116
+ )
117
+ return
118
+ if (command.action === "status") client.views.clear()
119
+ for (const view of views) client.views.set(view.viewId, view)
120
+ }
121
+ attempt.receipt = true
122
+ attempt.settle(outcome)
123
+ },
124
+ active(active) {
125
+ if (disposed || instance.disposed) return
126
+ const previous = client.active
127
+ client.active = active
128
+ if (
129
+ previous?.scopeID === active?.scopeID &&
130
+ previous?.sessionID === active?.sessionID &&
131
+ previous?.generation === active?.generation
132
+ )
133
+ return
134
+ for (const attempt of client.attempts.values())
135
+ attempt.cancel?.("The frontend changed its active view; no receipt confirms the effect.")
136
+ client.views.clear()
137
+ },
138
+ dispose() {
139
+ if (disposed) return
140
+ disposed = true
141
+ instance.clients.delete(clientId)
142
+ for (const attempt of client.attempts.values()) {
143
+ attempt.settle?.(unknown("The frontend disconnected; no receipt confirms the effect."))
144
+ attempt.receipt = false
145
+ attempt.result = {
146
+ requestId: attempt.caller.requestId,
147
+ ...unknown("The original frontend connection is gone; its receipt is no longer retained."),
148
+ }
149
+ attempt.promise = Promise.resolve(attempt.result)
150
+ }
151
+ client.attempts.clear()
152
+ client.views.clear()
153
+ client.targets = undefined
154
+ client.active = undefined
155
+ },
156
+ }
157
+ },
158
+ }
159
+ }
160
+
161
+ export function request(parameters: WorkbenchView.Parameters, caller: Caller): Promise<WorkbenchView.Result> {
162
+ const instance = state()
163
+ const { input, clientId } = parameters
164
+ if (input.action === "status" && input.requestId) {
165
+ const attempt = instance.attempts.get(key({ ...caller, requestId: input.requestId }))
166
+ return Promise.resolve(
167
+ attempt?.receipt && attempt.result
168
+ ? attempt.result
169
+ : { requestId: input.requestId, ...unknown("No confirmed receipt is retained for this Scope and Session.") },
170
+ )
171
+ }
172
+ const signature = createHash("sha256")
173
+ .update(JSON.stringify({ parameters, context: caller.context }))
174
+ .digest("hex")
175
+ const identity = { scopeID: caller.scopeID, sessionID: caller.sessionID, requestId: caller.requestId }
176
+ const existing = instance.attempts.get(key(caller))
177
+ if (existing)
178
+ return existing.signature === signature
179
+ ? existing.result
180
+ ? Promise.resolve(existing.result)
181
+ : existing.promise
182
+ : Promise.resolve({
183
+ requestId: caller.requestId,
184
+ status: "blocked",
185
+ reason: "This tool call identity already has different arguments; it cannot be reused.",
186
+ })
187
+ const immediate = (outcome: WorkbenchView.Outcome) => {
188
+ const result = { requestId: caller.requestId, ...outcome }
189
+ const promise = Promise.resolve(result)
190
+ if (!instance.disposed)
191
+ instance.attempts.set(key(caller), { caller: identity, signature, promise, result, receipt: false })
192
+ return promise
193
+ }
194
+ if (caller.signal.aborted) return immediate({ status: "cancelled", reason: "Cancelled before dispatch." })
195
+ if (instance.disposed)
196
+ return immediate({ status: "unavailable", reason: "The Workbench presentation service is closed." })
197
+ if (input.action === "open" && input.target.kind === "workspace-file" && caller.context?.state !== "bound")
198
+ return immediate({
199
+ status: "blocked",
200
+ reason:
201
+ "Opening a Workspace file requires the caller Session's captured valid Workspace; a viewer's Workspace cannot be used.",
202
+ })
203
+ let target = input.action === "open" ? input.target : undefined
204
+ if (target?.kind === "workspace-file") {
205
+ const relative = ResourceReference.resolvePath(target.path, caller.context ?? { state: "none" })
206
+ if (!relative)
207
+ return immediate({ status: "blocked", reason: "The file is not inside the caller's captured Workspace." })
208
+ target = { ...target, path: relative }
209
+ }
210
+ const clients = [...instance.clients.values()].filter(
211
+ (client) =>
212
+ client.active?.scopeID === caller.scopeID &&
213
+ client.active.sessionID === caller.sessionID &&
214
+ client.targets?.size &&
215
+ (input.action !== "open" || client.targets.has(input.target.kind)),
216
+ )
217
+ const selected = clientId
218
+ ? clients.find((client) => client.clientId === clientId)
219
+ : clients.length === 1
220
+ ? clients[0]
221
+ : undefined
222
+ if (!selected) {
223
+ if (!clientId && clients.length > 1)
224
+ return immediate({
225
+ status: "ambiguous",
226
+ reason: "Multiple eligible frontends show this Session. Choose a returned clientId; nothing was dispatched.",
227
+ clients: clients.map((client) => ({ clientId: client.clientId })),
228
+ })
229
+ return immediate({
230
+ status: "unavailable",
231
+ reason: clientId
232
+ ? "The selected clientId is not a ready, supported frontend actively showing this Scope and Session."
233
+ : "No ready frontend supporting this target is actively showing this Scope and Session.",
234
+ })
235
+ }
236
+ if (input.action === "hide") target = selected.views.get(input.viewId)?.target
237
+ if (input.action === "hide" && !target)
238
+ return immediate({
239
+ status: "blocked",
240
+ reason: "The selected frontend has no confirmed view with this viewId. Query current views before hiding it.",
241
+ })
242
+ let resolve!: (result: WorkbenchView.Result) => void
243
+ const promise = new Promise<WorkbenchView.Result>((done) => {
244
+ resolve = done
245
+ })
246
+ const attempt: Attempt = { caller: identity, command: input, target, signature, promise, receipt: false }
247
+ instance.attempts.set(key(caller), attempt)
248
+ selected.attempts.set(key(caller), attempt)
249
+ let timer: ReturnType<typeof setTimeout> | undefined
250
+ const aborted = () =>
251
+ attempt.cancel?.("Cancelled after dispatch; without a frontend receipt the effect is unknown.")
252
+ attempt.settle = (outcome) => {
253
+ if (!attempt.settle) return
254
+ clearTimeout(timer)
255
+ caller.signal.removeEventListener("abort", aborted)
256
+ attempt.settle = undefined
257
+ attempt.cancel = undefined
258
+ attempt.command = undefined
259
+ attempt.target = undefined
260
+ attempt.result = { requestId: caller.requestId, clientId: selected.clientId, ...outcome }
261
+ resolve(attempt.result)
262
+ }
263
+ attempt.cancel = (reason) => {
264
+ attempt.settle?.(unknown(reason))
265
+ selected.send({
266
+ scopeID: caller.scopeID,
267
+ payload: { type: "server.workbench.cancel", properties: { requestId: caller.requestId } },
268
+ })
269
+ }
270
+ caller.signal.addEventListener("abort", aborted, { once: true })
271
+ const deadline = performance.now() + (parameters.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS) * 1000
272
+ const wait = () => {
273
+ const remaining = deadline - performance.now()
274
+ if (remaining <= 0) {
275
+ attempt.cancel?.(
276
+ "The frontend receipt window expired; the effect is unknown. Check status without replaying the command.",
277
+ )
278
+ return
279
+ }
280
+ timer = setTimeout(wait, Math.min(remaining, 2_147_483_647))
281
+ timer.unref()
282
+ }
283
+ wait()
284
+ if (!attempt.settle) return promise
285
+ if (
286
+ !selected.send({
287
+ scopeID: caller.scopeID,
288
+ payload: {
289
+ type: "server.workbench.request",
290
+ properties: {
291
+ requestId: caller.requestId,
292
+ scopeID: caller.scopeID,
293
+ sessionID: caller.sessionID,
294
+ command: input,
295
+ ...(caller.context && { context: caller.context }),
296
+ },
297
+ },
298
+ })
299
+ )
300
+ attempt.cancel?.("The request could not be confirmed as sent; without a frontend receipt the effect is unknown.")
301
+ return promise
302
+ }
303
+ }
@@ -0,0 +1,75 @@
1
+ import { createHash } from "node:crypto"
2
+ import { ScopeContext } from "@ericsanchezok/synergy-harness/scope/context"
3
+ import { Session } from "@ericsanchezok/synergy-harness/session"
4
+ import { Tool } from "@ericsanchezok/synergy-harness/tool/tool"
5
+ import { ResourceReference } from "@ericsanchezok/synergy-util/resource-reference"
6
+ import { FileView } from "@ericsanchezok/synergy-local-runtime/file/view"
7
+ import { WorkbenchView } from "@ericsanchezok/synergy-util/workbench-view"
8
+ import { WorkbenchPresentation } from "../service"
9
+
10
+ export const WorkbenchViewTool = Tool.define(
11
+ "workbench_view",
12
+ {
13
+ description:
14
+ "Show an existing note, Workspace file, Browser page or immutable asset in a frontend actively viewing this Session; hide a returned viewId or inspect views. This only presents existing resources: it does not create resources, navigate Browser pages or perform arbitrary UI actions. With multiple eligible frontends, choose a returned clientId. Applied means the frontend confirmed presentation, not that loading content is ready. Waits 30 seconds for a receipt by default; timeout, disconnection or cancellation after dispatch may leave the effect unknown. Use status with the returned requestId to read the retained runtime receipt without replay. Reconnecting and conversation history never replay commands.",
15
+ parameters: WorkbenchView.Parameters,
16
+ resourceNeeds(args) {
17
+ const parsed = WorkbenchView.Parameters.safeParse(args)
18
+ return {
19
+ workspace:
20
+ parsed.success && parsed.data.input.action === "open" && parsed.data.input.target.kind === "workspace-file",
21
+ }
22
+ },
23
+ async execute(parameters, ctx) {
24
+ const scopeID = ScopeContext.current.scope.id
25
+ const requestId =
26
+ ctx.messageID && ctx.callID
27
+ ? `wvr_${createHash("sha256")
28
+ .update(JSON.stringify([scopeID, ctx.sessionID, ctx.messageID, ctx.callID]))
29
+ .digest("hex")}`
30
+ : `wvr_${crypto.randomUUID()}`
31
+ let context: ResourceReference.Context | undefined
32
+ if (parameters.input.action === "open" && parameters.input.target.kind === "workspace-file") {
33
+ const session = await Session.get(ctx.sessionID)
34
+ const selected = ctx.resources?.workspace
35
+ if (
36
+ session.scope.id === scopeID &&
37
+ selected?.scopeID === scopeID &&
38
+ selected.id === session.workspaceID &&
39
+ (!session.workspace || selected.binding.generation === session.workspace.generation)
40
+ )
41
+ context = ResourceReference.capture({
42
+ id: selected.id,
43
+ generation: selected.binding.generation,
44
+ path: ctx.resources?.directory ?? selected.binding.path ?? "",
45
+ })
46
+ context ??= { state: "none" }
47
+ if (context.state === "bound") {
48
+ const relative = ResourceReference.resolvePath(parameters.input.target.path, context)
49
+ if (relative)
50
+ await ctx.ask({
51
+ permission: "read",
52
+ patterns: [FileView.resolve(relative, context.workspace.root)],
53
+ metadata: {},
54
+ })
55
+ }
56
+ }
57
+ const result = await WorkbenchPresentation.request(parameters, {
58
+ scopeID,
59
+ sessionID: ctx.sessionID,
60
+ requestId,
61
+ context,
62
+ signal: ctx.abort,
63
+ })
64
+ return { title: `Workbench: ${result.status}`, metadata: { ...result }, output: JSON.stringify(result) }
65
+ },
66
+ },
67
+ {
68
+ requiresWorkspace: false,
69
+ exposure: {
70
+ mode: "search",
71
+ title: "Workbench view",
72
+ keywords: ["show", "open", "preview", "note", "file", "asset", "browser", "panel"],
73
+ },
74
+ },
75
+ )
@@ -0,0 +1,11 @@
1
+ import { RuntimeContext } from "@ericsanchezok/synergy-harness/lifecycle/context"
2
+ import { ToolRegistry } from "@ericsanchezok/synergy-harness/tool/registry"
3
+ import { WorkbenchViewTool } from "./tools/workbench-view"
4
+
5
+ const registered = RuntimeContext.state(() => ({ value: false }))
6
+
7
+ export function registerPresentationTools() {
8
+ if (registered().value) return
9
+ ToolRegistry.registerToolProvider("workbench-presentation", () => [WorkbenchViewTool])
10
+ registered().value = true
11
+ }