backeer-opencode 0.1.1 → 0.1.3
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/CHANGELOG.md +16 -0
- package/README.md +40 -2
- package/bin/backeer-opencode.js +1 -1
- package/dist/backeer-permission.js +730 -0
- package/package.json +9 -2
- package/src/config.js +56 -0
- package/src/event-publishers.js +214 -0
- package/src/http.js +87 -0
- package/src/index.js +1 -716
- package/src/permission-controller.js +183 -0
- package/src/plugin.js +143 -0
- package/src/reporter.js +60 -0
- package/src/sse.js +139 -0
- package/src/utilities.js +25 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { HttpError, postJsonWithRetry } from "./http.js"
|
|
2
|
+
import { runCommandStream } from "./sse.js"
|
|
3
|
+
import { rememberBounded } from "./utilities.js"
|
|
4
|
+
|
|
5
|
+
const ALLOWED_DECISIONS = new Set(["once", "always", "reject"])
|
|
6
|
+
const DECISION_LABELS = {
|
|
7
|
+
once: "Allow once",
|
|
8
|
+
always: "Always allow",
|
|
9
|
+
reject: "Reject",
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function replyToOpenCode(client, pending, decision) {
|
|
13
|
+
const result = await client.postSessionIdPermissionsPermissionId({
|
|
14
|
+
path: { id: pending.sessionID, permissionID: pending.requestID },
|
|
15
|
+
query: { directory: pending.directory },
|
|
16
|
+
body: { response: decision },
|
|
17
|
+
throwOnError: true,
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
if (result?.data === false || result === false) {
|
|
21
|
+
throw new HttpError("OpenCode did not apply the permission reply", 409)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function acknowledgeCommand(config, commandID, acknowledgement) {
|
|
26
|
+
const url = new URL(
|
|
27
|
+
`/v1/commands/${encodeURIComponent(commandID)}/ack`,
|
|
28
|
+
config.origin,
|
|
29
|
+
)
|
|
30
|
+
await postJsonWithRetry(config, url, acknowledgement)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createPermissionController({
|
|
34
|
+
config,
|
|
35
|
+
reporter,
|
|
36
|
+
client,
|
|
37
|
+
directory,
|
|
38
|
+
}) {
|
|
39
|
+
const pendingByExternalID = new Map()
|
|
40
|
+
const externalIDByRequestID = new Map()
|
|
41
|
+
const processingCommands = new Set()
|
|
42
|
+
const completedCommands = new Map()
|
|
43
|
+
const publishedPermissions = new Map()
|
|
44
|
+
let commandStream
|
|
45
|
+
let disposed = false
|
|
46
|
+
|
|
47
|
+
function removePendingByRequestID(requestID) {
|
|
48
|
+
const externalId = externalIDByRequestID.get(requestID)
|
|
49
|
+
if (!externalId) return
|
|
50
|
+
externalIDByRequestID.delete(requestID)
|
|
51
|
+
pendingByExternalID.delete(externalId)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function stopCommandStreamIfIdle() {
|
|
55
|
+
if (pendingByExternalID.size === 0) commandStream?.controller.abort()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function handleCommand(command) {
|
|
59
|
+
if (!command || typeof command !== "object") return
|
|
60
|
+
if (command.type !== "opencode_permission_reply") return
|
|
61
|
+
if (typeof command.id !== "string" || command.id.length > 200) return
|
|
62
|
+
|
|
63
|
+
const previousAcknowledgement = completedCommands.get(command.id)
|
|
64
|
+
if (previousAcknowledgement) {
|
|
65
|
+
await acknowledgeCommand(config, command.id, previousAcknowledgement)
|
|
66
|
+
.catch((error) => reporter.warning("Decision receipt retry failed", error))
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
if (processingCommands.has(command.id)) return
|
|
70
|
+
if (typeof command.externalId !== "string") return
|
|
71
|
+
|
|
72
|
+
const pending = pendingByExternalID.get(command.externalId)
|
|
73
|
+
if (!pending) return
|
|
74
|
+
|
|
75
|
+
processingCommands.add(command.id)
|
|
76
|
+
let acknowledgement
|
|
77
|
+
try {
|
|
78
|
+
if (!ALLOWED_DECISIONS.has(command.decision)) {
|
|
79
|
+
acknowledgement = { status: "rejected", errorCode: "invalid_decision" }
|
|
80
|
+
} else {
|
|
81
|
+
const expiresAt = Date.parse(command.expiresAt)
|
|
82
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) {
|
|
83
|
+
acknowledgement = { status: "expired", decision: command.decision }
|
|
84
|
+
} else if (command.decision === "always" && command.confirmed !== true) {
|
|
85
|
+
acknowledgement = {
|
|
86
|
+
status: "rejected",
|
|
87
|
+
decision: command.decision,
|
|
88
|
+
errorCode: "confirmation_required",
|
|
89
|
+
}
|
|
90
|
+
} else {
|
|
91
|
+
await replyToOpenCode(client, pending, command.decision)
|
|
92
|
+
acknowledgement = { status: "applied", decision: command.decision }
|
|
93
|
+
removePendingByRequestID(pending.requestID)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
} catch (error) {
|
|
97
|
+
const stale = error instanceof HttpError && error.status === 404
|
|
98
|
+
acknowledgement = {
|
|
99
|
+
status: stale ? "stale" : "failed",
|
|
100
|
+
decision: ALLOWED_DECISIONS.has(command.decision)
|
|
101
|
+
? command.decision
|
|
102
|
+
: undefined,
|
|
103
|
+
errorCode: stale ? "permission_not_found" : "opencode_reply_failed",
|
|
104
|
+
}
|
|
105
|
+
if (stale) removePendingByRequestID(pending.requestID)
|
|
106
|
+
reporter.error("Decision could not be applied", error)
|
|
107
|
+
} finally {
|
|
108
|
+
processingCommands.delete(command.id)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
acknowledgement = {
|
|
112
|
+
...acknowledgement,
|
|
113
|
+
type: "opencode_permission_reply_ack",
|
|
114
|
+
externalId: command.externalId,
|
|
115
|
+
processedAt: new Date().toISOString(),
|
|
116
|
+
}
|
|
117
|
+
rememberBounded(completedCommands, command.id, acknowledgement)
|
|
118
|
+
|
|
119
|
+
if (acknowledgement.status === "applied") {
|
|
120
|
+
reporter.applied(
|
|
121
|
+
`Decision applied: ${DECISION_LABELS[acknowledgement.decision]}`,
|
|
122
|
+
)
|
|
123
|
+
} else if (acknowledgement.status === "expired") {
|
|
124
|
+
reporter.warning("Decision expired before it could be applied")
|
|
125
|
+
} else if (acknowledgement.status === "stale") {
|
|
126
|
+
reporter.warning("Permission was already answered locally")
|
|
127
|
+
} else if (acknowledgement.status === "rejected") {
|
|
128
|
+
reporter.warning("Decision was rejected by validation")
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
await acknowledgeCommand(config, command.id, acknowledgement).catch((error) => {
|
|
132
|
+
reporter.warning("Decision receipt could not be sent", error)
|
|
133
|
+
})
|
|
134
|
+
stopCommandStreamIfIdle()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function ensureCommandStream() {
|
|
138
|
+
if (disposed || commandStream || pendingByExternalID.size === 0) return
|
|
139
|
+
|
|
140
|
+
const controller = new AbortController()
|
|
141
|
+
const current = {
|
|
142
|
+
controller,
|
|
143
|
+
task: runCommandStream(config, reporter, {
|
|
144
|
+
signal: controller.signal,
|
|
145
|
+
hasPending: () => pendingByExternalID.size > 0,
|
|
146
|
+
onCommand: handleCommand,
|
|
147
|
+
}),
|
|
148
|
+
}
|
|
149
|
+
commandStream = current
|
|
150
|
+
void current.task.finally(() => {
|
|
151
|
+
if (commandStream === current) commandStream = undefined
|
|
152
|
+
if (!disposed && pendingByExternalID.size > 0) ensureCommandStream()
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
register(identity) {
|
|
158
|
+
pendingByExternalID.set(identity.externalId, { ...identity, directory })
|
|
159
|
+
externalIDByRequestID.set(identity.requestID, identity.externalId)
|
|
160
|
+
ensureCommandStream()
|
|
161
|
+
|
|
162
|
+
if (publishedPermissions.has(identity.externalId)) return false
|
|
163
|
+
rememberBounded(publishedPermissions, identity.externalId, true)
|
|
164
|
+
return true
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
resolveLocally(requestID) {
|
|
168
|
+
removePendingByRequestID(requestID)
|
|
169
|
+
stopCommandStreamIfIdle()
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
async dispose() {
|
|
173
|
+
disposed = true
|
|
174
|
+
commandStream?.controller.abort()
|
|
175
|
+
await commandStream?.task.catch(() => {})
|
|
176
|
+
pendingByExternalID.clear()
|
|
177
|
+
externalIDByRequestID.clear()
|
|
178
|
+
processingCommands.clear()
|
|
179
|
+
completedCommands.clear()
|
|
180
|
+
publishedPermissions.clear()
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
}
|
package/src/plugin.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { loadConfig } from "./config.js"
|
|
2
|
+
import {
|
|
3
|
+
permissionIdentity,
|
|
4
|
+
publishPermission,
|
|
5
|
+
publishQuestion,
|
|
6
|
+
publishTodoCompletion,
|
|
7
|
+
questionIdentity,
|
|
8
|
+
} from "./event-publishers.js"
|
|
9
|
+
import { createPermissionController } from "./permission-controller.js"
|
|
10
|
+
import { createConsoleReporter } from "./reporter.js"
|
|
11
|
+
import { rememberBounded } from "./utilities.js"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* OpenCode server plugin entrypoint.
|
|
15
|
+
*
|
|
16
|
+
* Supported options: origin, tokenFile, permissions, questions, todos, logLevel.
|
|
17
|
+
* The ingest token is intentionally accepted only through the environment or a
|
|
18
|
+
* protected file so it cannot accidentally be committed in opencode.json.
|
|
19
|
+
*/
|
|
20
|
+
export default async function BackeerPlugin({ client, directory }, options = {}) {
|
|
21
|
+
const bootstrapReporter = createConsoleReporter(
|
|
22
|
+
options.logLevel ?? process.env.BACKEER_LOG_LEVEL,
|
|
23
|
+
)
|
|
24
|
+
let config
|
|
25
|
+
try {
|
|
26
|
+
config = loadConfig(options)
|
|
27
|
+
} catch (error) {
|
|
28
|
+
bootstrapReporter.error("Plugin disabled — invalid configuration", error)
|
|
29
|
+
return {}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const reporter = createConsoleReporter(config.logLevel)
|
|
33
|
+
|
|
34
|
+
if (!config.token) {
|
|
35
|
+
reporter.error(
|
|
36
|
+
"Plugin disabled — run: npx backeer-opencode@latest configure",
|
|
37
|
+
`Token not found at ${config.tokenFile}`,
|
|
38
|
+
)
|
|
39
|
+
return {}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const permissionController = createPermissionController({
|
|
43
|
+
config,
|
|
44
|
+
reporter,
|
|
45
|
+
client,
|
|
46
|
+
directory,
|
|
47
|
+
})
|
|
48
|
+
const todoCompletionBySessionID = new Map()
|
|
49
|
+
const publishedQuestions = new Map()
|
|
50
|
+
|
|
51
|
+
async function handlePermissionAsked(event) {
|
|
52
|
+
const identity = permissionIdentity(event.properties)
|
|
53
|
+
if (!identity) {
|
|
54
|
+
reporter.error("Permission event is missing correlation IDs")
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
if (!permissionController.register(identity)) return
|
|
58
|
+
|
|
59
|
+
void publishPermission(
|
|
60
|
+
config,
|
|
61
|
+
reporter,
|
|
62
|
+
event.properties,
|
|
63
|
+
directory,
|
|
64
|
+
identity,
|
|
65
|
+
).catch((error) => reporter.error("Permission request could not be sent", error))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function handleTodoUpdated(event) {
|
|
69
|
+
const { sessionID, todos } = event.properties
|
|
70
|
+
if (typeof sessionID !== "string" || !Array.isArray(todos)) {
|
|
71
|
+
reporter.error("Todo event is missing a valid session or list")
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const isComplete = todos.length > 0 &&
|
|
76
|
+
todos.every((todo) => todo?.status === "completed")
|
|
77
|
+
const wasComplete = todoCompletionBySessionID.get(sessionID) === true
|
|
78
|
+
rememberBounded(todoCompletionBySessionID, sessionID, isComplete)
|
|
79
|
+
if (!isComplete || wasComplete) return
|
|
80
|
+
|
|
81
|
+
void publishTodoCompletion(
|
|
82
|
+
config,
|
|
83
|
+
reporter,
|
|
84
|
+
event.properties,
|
|
85
|
+
event.id,
|
|
86
|
+
client,
|
|
87
|
+
directory,
|
|
88
|
+
).catch((error) => reporter.error("Todo completion could not be sent", error))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function handleQuestionAsked(event) {
|
|
92
|
+
const identity = questionIdentity(event.properties)
|
|
93
|
+
if (!identity) {
|
|
94
|
+
reporter.error("Question event is missing correlation IDs")
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
if (publishedQuestions.has(identity.externalId)) return
|
|
98
|
+
rememberBounded(publishedQuestions, identity.externalId, true)
|
|
99
|
+
|
|
100
|
+
void publishQuestion(
|
|
101
|
+
config,
|
|
102
|
+
reporter,
|
|
103
|
+
event.properties,
|
|
104
|
+
identity,
|
|
105
|
+
client,
|
|
106
|
+
directory,
|
|
107
|
+
).catch((error) => reporter.error("Question could not be sent", error))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
event: async ({ event }) => {
|
|
112
|
+
if (config.permissions && event.type === "permission.asked") {
|
|
113
|
+
await handlePermissionAsked(event)
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (config.permissions && event.type === "permission.replied") {
|
|
118
|
+
permissionController.resolveLocally(
|
|
119
|
+
event.properties.requestID ?? event.properties.id,
|
|
120
|
+
)
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (config.todos && event.type === "todo.updated") {
|
|
125
|
+
await handleTodoUpdated(event)
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (
|
|
130
|
+
config.questions &&
|
|
131
|
+
(event.type === "question.asked" || event.type === "question.v2.asked")
|
|
132
|
+
) {
|
|
133
|
+
await handleQuestionAsked(event)
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
dispose: async () => {
|
|
138
|
+
await permissionController.dispose()
|
|
139
|
+
todoCompletionBySessionID.clear()
|
|
140
|
+
publishedQuestions.clear()
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
}
|
package/src/reporter.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { limit } from "./utilities.js"
|
|
2
|
+
|
|
3
|
+
const LOG_LEVELS = new Set(["silent", "error", "info", "debug"])
|
|
4
|
+
const LOG_RANK = { silent: 0, error: 1, info: 2, debug: 3 }
|
|
5
|
+
const LOG_DEDUPLICATION_MS = 30_000
|
|
6
|
+
|
|
7
|
+
export function normalizeLogLevel(value) {
|
|
8
|
+
const level = String(value ?? "info").trim().toLowerCase()
|
|
9
|
+
return LOG_LEVELS.has(level) ? level : "info"
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createConsoleReporter(levelValue) {
|
|
13
|
+
const level = normalizeLogLevel(levelValue)
|
|
14
|
+
const useColor = Boolean(process.stdout?.isTTY) &&
|
|
15
|
+
!("NO_COLOR" in process.env) && process.env.TERM !== "dumb"
|
|
16
|
+
const recentlyEmitted = new Map()
|
|
17
|
+
|
|
18
|
+
const decorate = (code, text) => useColor ? `\u001b[${code}m${text}\u001b[0m` : text
|
|
19
|
+
const symbols = {
|
|
20
|
+
info: decorate("36", "◆"),
|
|
21
|
+
success: decorate("32", "◆"),
|
|
22
|
+
applied: decorate("32", "✓"),
|
|
23
|
+
warning: decorate("33", "!"),
|
|
24
|
+
error: decorate("31", "×"),
|
|
25
|
+
debug: decorate("2", "·"),
|
|
26
|
+
}
|
|
27
|
+
const brand = decorate("1", "Backeer")
|
|
28
|
+
|
|
29
|
+
function emit(requiredLevel, kind, message, detail, deduplicate = false) {
|
|
30
|
+
if (LOG_RANK[level] < LOG_RANK[requiredLevel]) return
|
|
31
|
+
|
|
32
|
+
const key = `${kind}:${message}`
|
|
33
|
+
const now = Date.now()
|
|
34
|
+
if (deduplicate) {
|
|
35
|
+
const previous = recentlyEmitted.get(key)
|
|
36
|
+
if (previous && now - previous < LOG_DEDUPLICATION_MS) return
|
|
37
|
+
recentlyEmitted.delete(key)
|
|
38
|
+
recentlyEmitted.set(key, now)
|
|
39
|
+
while (recentlyEmitted.size > 100) {
|
|
40
|
+
recentlyEmitted.delete(recentlyEmitted.keys().next().value)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const technical = level === "debug" && detail
|
|
45
|
+
? decorate("2", ` · ${limit(detail, 500)}`)
|
|
46
|
+
: ""
|
|
47
|
+
const line = `${symbols[kind]} ${brand} ${message}${technical}`
|
|
48
|
+
if (kind === "error" || kind === "warning") console.error(line)
|
|
49
|
+
else console.log(line)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
info: (message, detail) => emit("info", "info", message, detail),
|
|
54
|
+
success: (message, detail) => emit("info", "success", message, detail),
|
|
55
|
+
applied: (message, detail) => emit("info", "applied", message, detail),
|
|
56
|
+
warning: (message, detail) => emit("error", "warning", message, detail, true),
|
|
57
|
+
error: (message, detail) => emit("error", "error", message, detail, true),
|
|
58
|
+
debug: (message, detail) => emit("debug", "debug", message, detail),
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/sse.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { HttpError, isAbortError, sleep } from "./http.js"
|
|
2
|
+
import { limit } from "./utilities.js"
|
|
3
|
+
|
|
4
|
+
export function parseSseFrame(frame) {
|
|
5
|
+
const data = []
|
|
6
|
+
let event = "message"
|
|
7
|
+
let id
|
|
8
|
+
|
|
9
|
+
for (const line of frame.split(/\r?\n/)) {
|
|
10
|
+
if (!line || line.startsWith(":")) continue
|
|
11
|
+
const separator = line.indexOf(":")
|
|
12
|
+
const field = separator === -1 ? line : line.slice(0, separator)
|
|
13
|
+
let value = separator === -1 ? "" : line.slice(separator + 1)
|
|
14
|
+
if (value.startsWith(" ")) value = value.slice(1)
|
|
15
|
+
|
|
16
|
+
if (field === "event") event = value
|
|
17
|
+
if (field === "id") id = value
|
|
18
|
+
if (field === "data") data.push(value)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (data.length === 0) return
|
|
22
|
+
return { event, id, data: data.join("\n") }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function nextSseFrame(buffer) {
|
|
26
|
+
const lfIndex = buffer.indexOf("\n\n")
|
|
27
|
+
const crlfIndex = buffer.indexOf("\r\n\r\n")
|
|
28
|
+
if (lfIndex === -1 && crlfIndex === -1) return
|
|
29
|
+
|
|
30
|
+
if (crlfIndex !== -1 && (lfIndex === -1 || crlfIndex < lfIndex)) {
|
|
31
|
+
return {
|
|
32
|
+
frame: buffer.slice(0, crlfIndex),
|
|
33
|
+
rest: buffer.slice(crlfIndex + 4),
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return { frame: buffer.slice(0, lfIndex), rest: buffer.slice(lfIndex + 2) }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function consumeSse(response, state, onCommand, signal, reporter) {
|
|
40
|
+
if (!response.body) throw new Error("Backeer command stream has no body")
|
|
41
|
+
|
|
42
|
+
const reader = response.body.getReader()
|
|
43
|
+
const decoder = new TextDecoder()
|
|
44
|
+
let buffer = ""
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
while (!signal.aborted) {
|
|
48
|
+
const item = await reader.read()
|
|
49
|
+
if (item.done) break
|
|
50
|
+
buffer += decoder.decode(item.value, { stream: true })
|
|
51
|
+
|
|
52
|
+
for (;;) {
|
|
53
|
+
const next = nextSseFrame(buffer)
|
|
54
|
+
if (!next) break
|
|
55
|
+
buffer = next.rest
|
|
56
|
+
const message = parseSseFrame(next.frame)
|
|
57
|
+
if (!message) continue
|
|
58
|
+
if (message.id) state.lastEventID = message.id
|
|
59
|
+
if (message.event !== "message" && message.event !== "command") continue
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
await onCommand(JSON.parse(message.data))
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (error instanceof SyntaxError) {
|
|
65
|
+
reporter.error("Invalid command received", "SSE data is not valid JSON")
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
throw error
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
} finally {
|
|
73
|
+
await reader.cancel().catch(() => {})
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function runCommandStream(
|
|
78
|
+
config,
|
|
79
|
+
reporter,
|
|
80
|
+
{ signal, hasPending, onCommand },
|
|
81
|
+
) {
|
|
82
|
+
const state = { lastEventID: "" }
|
|
83
|
+
let reconnectDelay = 1000
|
|
84
|
+
let lastLoggedError = ""
|
|
85
|
+
|
|
86
|
+
while (!signal.aborted && hasPending()) {
|
|
87
|
+
try {
|
|
88
|
+
const headers = {
|
|
89
|
+
Accept: "text/event-stream",
|
|
90
|
+
Authorization: `Bearer ${config.token}`,
|
|
91
|
+
}
|
|
92
|
+
if (state.lastEventID) headers["Last-Event-ID"] = state.lastEventID
|
|
93
|
+
|
|
94
|
+
const response = await fetch(config.commandStreamEndpoint, {
|
|
95
|
+
headers,
|
|
96
|
+
signal,
|
|
97
|
+
})
|
|
98
|
+
if (!response.ok) {
|
|
99
|
+
const responseBody = limit(await response.text(), 500)
|
|
100
|
+
throw new HttpError(
|
|
101
|
+
`Backeer command stream failed with status ${response.status}`,
|
|
102
|
+
response.status,
|
|
103
|
+
responseBody,
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
if (!response.headers.get("content-type")?.includes("text/event-stream")) {
|
|
107
|
+
throw new Error("Backeer command stream returned an invalid content type")
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
reconnectDelay = 1000
|
|
111
|
+
lastLoggedError = ""
|
|
112
|
+
await consumeSse(response, state, onCommand, signal, reporter)
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (signal.aborted || isAbortError(error)) return
|
|
115
|
+
const message = error instanceof HttpError
|
|
116
|
+
? `${error.message}: ${error.responseBody}`
|
|
117
|
+
: limit(error, 500)
|
|
118
|
+
if (message !== lastLoggedError) {
|
|
119
|
+
const authenticationFailure = error instanceof HttpError &&
|
|
120
|
+
[401, 403].includes(error.status)
|
|
121
|
+
reporter.error(
|
|
122
|
+
authenticationFailure
|
|
123
|
+
? "Authentication failed — run: npx backeer-opencode@latest configure"
|
|
124
|
+
: "Connection interrupted — retrying",
|
|
125
|
+
message,
|
|
126
|
+
)
|
|
127
|
+
lastLoggedError = message
|
|
128
|
+
}
|
|
129
|
+
if (error instanceof HttpError && [401, 403, 404].includes(error.status)) {
|
|
130
|
+
reconnectDelay = 60_000
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!signal.aborted && hasPending()) {
|
|
135
|
+
await sleep(reconnectDelay, signal)
|
|
136
|
+
reconnectDelay = Math.min(reconnectDelay * 2, 60_000)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
package/src/utilities.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const MAX_TRACKED_ITEMS = 500
|
|
2
|
+
|
|
3
|
+
export const limit = (value, length = 1000) =>
|
|
4
|
+
String(value ?? "").slice(0, length)
|
|
5
|
+
|
|
6
|
+
export function enabled(value, fallback = true) {
|
|
7
|
+
if (typeof value === "boolean") return value
|
|
8
|
+
if (typeof value !== "string") return fallback
|
|
9
|
+
return !["0", "false", "no", "off"].includes(value.trim().toLowerCase())
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function stableHash(value) {
|
|
13
|
+
let hash = 2166136261
|
|
14
|
+
for (let index = 0; index < value.length; index++) {
|
|
15
|
+
hash ^= value.charCodeAt(index)
|
|
16
|
+
hash = Math.imul(hash, 16777619)
|
|
17
|
+
}
|
|
18
|
+
return (hash >>> 0).toString(16).padStart(8, "0")
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function rememberBounded(map, key, value) {
|
|
22
|
+
map.delete(key)
|
|
23
|
+
map.set(key, value)
|
|
24
|
+
while (map.size > MAX_TRACKED_ITEMS) map.delete(map.keys().next().value)
|
|
25
|
+
}
|