openvisio-agent 0.19.9 → 0.19.11
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/README.md +9 -8
- package/bin/cli.mjs +15 -8
- package/package.json +2 -2
- package/scripts/certify.mjs +23 -18
- package/src/assignment-routing.mjs +32 -0
- package/src/authorization-resume.mjs +10 -0
- package/src/channel-routing.mjs +26 -0
- package/src/codex-mcp-proxy.mjs +3 -1
- package/src/concurrency.mjs +12 -0
- package/src/cycle-queue.mjs +5 -1
- package/src/mastra-harness.mjs +68 -32
- package/src/memory.mjs +15 -7
- package/src/opencode-config.mjs +1 -0
- package/src/pr-push.mjs +56 -17
- package/src/runner-pool.mjs +38 -0
- package/src/task-types.mjs +24 -0
- package/src/watch.mjs +268 -488
package/src/memory.mjs
CHANGED
|
@@ -44,6 +44,10 @@ export function createByoMemoryGraph({ path, maxNodes = 1000, now = () => Date.n
|
|
|
44
44
|
meta: { ...(previous?.meta || {}), ...meta },
|
|
45
45
|
updatedAt: stamp,
|
|
46
46
|
}
|
|
47
|
+
// Reconciliation frequently sees the same revision. Avoid rewriting both
|
|
48
|
+
// stores and appending identical context on every sweep.
|
|
49
|
+
if (previous && previous.kind === node.kind && previous.state === node.state && previous.summary === node.summary &&
|
|
50
|
+
JSON.stringify(previous.refs) === JSON.stringify(node.refs) && JSON.stringify(previous.meta) === JSON.stringify(node.meta)) return previous
|
|
47
51
|
nodes.set(id, node); trim(); persist()
|
|
48
52
|
return node
|
|
49
53
|
}
|
|
@@ -80,7 +84,7 @@ export function createByoMemoryGraph({ path, maxNodes = 1000, now = () => Date.n
|
|
|
80
84
|
return !!node && (state == null || node.state === state)
|
|
81
85
|
}
|
|
82
86
|
|
|
83
|
-
return { remember, connect, recall, context, has, persist }
|
|
87
|
+
return { remember, connect, recall, context, has, persist, get: (key) => nodes.get(String(key)) }
|
|
84
88
|
}
|
|
85
89
|
|
|
86
90
|
const memoryThreadId = (refs = {}) => {
|
|
@@ -103,14 +107,14 @@ export function createMastraMemory({ ledgerPath, databasePath, resourceId, maxNo
|
|
|
103
107
|
const storage = new LibSQLStore({ id: `openvisio-memory-${resourceId}`, url: `file:${databasePath}` })
|
|
104
108
|
const memory = new Memory({ storage, options: { lastMessages: 12, semanticRecall: false, workingMemory: { enabled: false } } })
|
|
105
109
|
const threads = new Map()
|
|
106
|
-
|
|
110
|
+
const pending = new Map()
|
|
107
111
|
|
|
108
112
|
const ensureThread = (threadId) => {
|
|
109
113
|
if (!threads.has(threadId)) {
|
|
110
114
|
threads.set(threadId, (async () => {
|
|
111
115
|
const existing = await memory.getThreadById({ threadId, resourceId })
|
|
112
116
|
return existing || memory.createThread({ threadId, resourceId, title: threadId })
|
|
113
|
-
})())
|
|
117
|
+
})().catch((error) => { threads.delete(threadId); throw error }))
|
|
114
118
|
}
|
|
115
119
|
return threads.get(threadId)
|
|
116
120
|
}
|
|
@@ -118,7 +122,7 @@ export function createMastraMemory({ ledgerPath, databasePath, resourceId, maxNo
|
|
|
118
122
|
const queueMessage = (entry) => {
|
|
119
123
|
const threadId = memoryThreadId(entry?.refs)
|
|
120
124
|
if (!threadId) return
|
|
121
|
-
|
|
125
|
+
const write = (pending.get(threadId) || Promise.resolve()).then(async () => {
|
|
122
126
|
await ensureThread(threadId)
|
|
123
127
|
const text = `${entry.kind || 'event'} ${entry.state || 'observed'}: ${entry.summary || entry.key}`
|
|
124
128
|
await memory.saveMessages({ messages: [{
|
|
@@ -126,18 +130,22 @@ export function createMastraMemory({ ledgerPath, databasePath, resourceId, maxNo
|
|
|
126
130
|
content: { format: 2, parts: [{ type: 'text', text }], metadata: { openvisioKey: entry.key } },
|
|
127
131
|
}] })
|
|
128
132
|
}).catch(() => { /* memory is best-effort; live backend checks stay authoritative */ })
|
|
133
|
+
pending.set(threadId, write)
|
|
134
|
+
void write.finally(() => { if (pending.get(threadId) === write) pending.delete(threadId) })
|
|
129
135
|
}
|
|
130
136
|
|
|
131
137
|
const remember = (entry) => {
|
|
138
|
+
const previous = ledger.get(entry.key)
|
|
132
139
|
const node = ledger.remember(entry)
|
|
133
|
-
if (node) queueMessage(node)
|
|
140
|
+
if (node && node !== previous) queueMessage(node)
|
|
134
141
|
return node
|
|
135
142
|
}
|
|
136
143
|
|
|
137
144
|
const context = async (refs = {}, limit = 8) => {
|
|
138
145
|
const threadId = memoryThreadId(refs)
|
|
139
146
|
if (!threadId) return ''
|
|
140
|
-
|
|
147
|
+
// Writes for unrelated tickets must never delay a conversation reply.
|
|
148
|
+
await pending.get(threadId)
|
|
141
149
|
try {
|
|
142
150
|
const recalled = await memory.recall({ threadId, resourceId, perPage: limit })
|
|
143
151
|
const items = recalled.messages.map(messageText).filter(Boolean).slice(-limit)
|
|
@@ -146,6 +154,6 @@ export function createMastraMemory({ ledgerPath, databasePath, resourceId, maxNo
|
|
|
146
154
|
return ledger.context(refs, limit)
|
|
147
155
|
}
|
|
148
156
|
|
|
149
|
-
const settled = async () => { await pending; await memory.settled() }
|
|
157
|
+
const settled = async () => { await Promise.all(pending.values()); await memory.settled() }
|
|
150
158
|
return { remember, connect: ledger.connect, recall: ledger.recall, context, has: ledger.has, persist: ledger.persist, settled, provider: 'mastra-libsql' }
|
|
151
159
|
}
|
package/src/opencode-config.mjs
CHANGED
package/src/pr-push.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process'
|
|
2
|
-
import { chmodSync,
|
|
2
|
+
import { chmodSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
|
|
3
3
|
import { homedir } from 'node:os'
|
|
4
|
-
import { dirname, join } from 'node:path'
|
|
4
|
+
import { dirname, join, relative, isAbsolute, resolve } from 'node:path'
|
|
5
|
+
import { writeJson } from './lib.mjs'
|
|
5
6
|
|
|
6
|
-
const DEFAULT_AUTH_PATH = join(homedir(), '.openvisio', 'pr-push-authorizations.json')
|
|
7
|
+
export const DEFAULT_AUTH_PATH = join(homedir(), '.openvisio', 'pr-push-authorizations.json')
|
|
7
8
|
const DEFAULT_RULE_PATH = join(homedir(), '.codex', 'rules', 'openvisio-agent.rules')
|
|
8
9
|
|
|
9
10
|
const gitText = (cwd, args, spawn = spawnSync) => {
|
|
@@ -32,7 +33,7 @@ export function isSafeAgentBranch(value) {
|
|
|
32
33
|
|
|
33
34
|
export function codexPrPushRule() {
|
|
34
35
|
return `# Generated by: openvisio-agent authorize-pr-push
|
|
35
|
-
# The helper enforces
|
|
36
|
+
# The helper enforces the configured workspace or exact repository + origin and only
|
|
36
37
|
# pushes the current agent/* branch. It accepts no force, remote, or ref args.
|
|
37
38
|
prefix_rule(
|
|
38
39
|
pattern = ["openvisio-agent", "push-pr-branch"],
|
|
@@ -50,6 +51,41 @@ function readAuthorizations(path) {
|
|
|
50
51
|
} catch { return [] }
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
function readPolicy(path) {
|
|
55
|
+
try { return JSON.parse(readFileSync(path, 'utf8')) } catch { return {} }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const within = (root, path) => {
|
|
59
|
+
if (!root || !path) return false
|
|
60
|
+
const rel = relative(root, path)
|
|
61
|
+
return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith('..' + (process.platform === 'win32' ? '\\' : '/')))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function policyAllows(identity, policy) {
|
|
65
|
+
const roots = [identity.root, identity.commonDir].filter(Boolean)
|
|
66
|
+
if ((policy.revoked || []).some((entry) => roots.includes(entry))) return false
|
|
67
|
+
return (policy.authorizations || []).some((entry) => entry.remote === identity.remote &&
|
|
68
|
+
(entry.root === identity.root || (entry.commonDir && entry.commonDir === identity.commonDir))) ||
|
|
69
|
+
(policy.workspaces || []).some((entry) => roots.some((root) => within(entry.root, root)))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Connecting a coding workspace grants routine PR delivery once, including
|
|
73
|
+
// future clones and worktrees. The helper still controls branch and destination.
|
|
74
|
+
export function configurePrPublishing({ workspace, enabled = true, authPath = DEFAULT_AUTH_PATH, rulePath = DEFAULT_RULE_PATH } = {}) {
|
|
75
|
+
if (!workspace) return null
|
|
76
|
+
mkdirSync(workspace, { recursive: true })
|
|
77
|
+
const root = realpathSync(workspace)
|
|
78
|
+
if (dirname(root) === root || root === homedir()) throw new Error('Choose a dedicated coding workspace, not the home directory or filesystem root.')
|
|
79
|
+
const policy = readPolicy(authPath)
|
|
80
|
+
const workspaces = (policy.workspaces || []).filter((entry) => entry.root !== root)
|
|
81
|
+
if (enabled) workspaces.push({ root })
|
|
82
|
+
if (JSON.stringify(workspaces) !== JSON.stringify(policy.workspaces || [])) {
|
|
83
|
+
writeJson(authPath, { ...policy, version: 2, workspaces }, true)
|
|
84
|
+
}
|
|
85
|
+
if (enabled) writePrivate(rulePath, codexPrPushRule())
|
|
86
|
+
return { root, enabled }
|
|
87
|
+
}
|
|
88
|
+
|
|
53
89
|
function writePrivate(path, value) {
|
|
54
90
|
mkdirSync(dirname(path), { recursive: true })
|
|
55
91
|
writeFileSync(path, value, { mode: 0o600 })
|
|
@@ -60,14 +96,15 @@ export function repositoryPushIdentity(cwd = process.cwd(), spawn = spawnSync) {
|
|
|
60
96
|
const root = realpathSync(gitText(cwd, ['rev-parse', '--show-toplevel'], spawn))
|
|
61
97
|
const remote = safeRemote(gitText(root, ['remote', 'get-url', '--push', 'origin'], spawn))
|
|
62
98
|
const branch = gitText(root, ['branch', '--show-current'], spawn)
|
|
63
|
-
|
|
99
|
+
const commonDir = realpathSync(resolve(root, gitText(root, ['rev-parse', '--git-common-dir'], spawn)))
|
|
100
|
+
return { root, commonDir, remote, branch }
|
|
64
101
|
}
|
|
65
102
|
|
|
66
|
-
export function validatePrPushAuthorization({ root, remote, branch, authorizations }) {
|
|
103
|
+
export function validatePrPushAuthorization({ root, commonDir, remote, branch, authorizations, workspaces = [], revoked = [] }) {
|
|
67
104
|
if (!isSafeAgentBranch(branch)) {
|
|
68
105
|
throw new Error(`Refusing to push branch "${branch || '(detached HEAD)'}". OpenVisio PR pushes require the current branch to match agent/*.`)
|
|
69
106
|
}
|
|
70
|
-
const allowed = (
|
|
107
|
+
const allowed = policyAllows({ root, commonDir, remote }, { authorizations, workspaces, revoked })
|
|
71
108
|
if (!allowed) {
|
|
72
109
|
throw new Error(`OPENVISIO_PR_PUSH_AUTH_REQUIRED: This repository and its exact origin are not authorized. From ${root}, run: openvisio-agent authorize-pr-push`)
|
|
73
110
|
}
|
|
@@ -76,37 +113,39 @@ export function validatePrPushAuthorization({ root, remote, branch, authorizatio
|
|
|
76
113
|
|
|
77
114
|
export function repositoryHasPrPushAuthorization({ cwd = process.cwd(), authPath = DEFAULT_AUTH_PATH, spawn = spawnSync } = {}) {
|
|
78
115
|
try {
|
|
79
|
-
|
|
80
|
-
return readAuthorizations(authPath).some((entry) => entry?.root === root && entry?.remote === remote)
|
|
116
|
+
return policyAllows(repositoryPushIdentity(cwd, spawn), readPolicy(authPath))
|
|
81
117
|
} catch { return false }
|
|
82
118
|
}
|
|
83
119
|
|
|
84
120
|
export function authorizePrPush({ cwd = process.cwd(), authPath = DEFAULT_AUTH_PATH, rulePath = DEFAULT_RULE_PATH, spawn = spawnSync, now = () => new Date().toISOString() } = {}) {
|
|
85
|
-
const { root, remote } = repositoryPushIdentity(cwd, spawn)
|
|
121
|
+
const { root, commonDir, remote } = repositoryPushIdentity(cwd, spawn)
|
|
122
|
+
const policy = readPolicy(authPath)
|
|
86
123
|
const authorizations = readAuthorizations(authPath).filter((entry) => entry?.root !== root)
|
|
87
|
-
authorizations.push({ root, remote, authorizedAt: now() })
|
|
88
|
-
|
|
124
|
+
authorizations.push({ root, commonDir, remote, authorizedAt: now() })
|
|
125
|
+
writeJson(authPath, { ...policy, version: 2, authorizations, revoked: (policy.revoked || []).filter((path) => path !== root && path !== commonDir) }, true)
|
|
89
126
|
writePrivate(rulePath, codexPrPushRule())
|
|
90
127
|
return { root, remote, authPath, rulePath }
|
|
91
128
|
}
|
|
92
129
|
|
|
93
130
|
export function revokePrPush({ cwd = process.cwd(), authPath = DEFAULT_AUTH_PATH, spawn = spawnSync } = {}) {
|
|
94
|
-
const root =
|
|
131
|
+
const { root, commonDir } = repositoryPushIdentity(cwd, spawn)
|
|
132
|
+
const policy = readPolicy(authPath)
|
|
95
133
|
const before = readAuthorizations(authPath)
|
|
96
134
|
const authorizations = before.filter((entry) => entry?.root !== root)
|
|
97
|
-
|
|
98
|
-
return { root, revoked:
|
|
135
|
+
writeJson(authPath, { ...policy, version: 2, authorizations, revoked: [...new Set([...(policy.revoked || []), commonDir])] }, true)
|
|
136
|
+
return { root, revoked: true }
|
|
99
137
|
}
|
|
100
138
|
|
|
101
139
|
export function pushPrBranch({ cwd = process.cwd(), authPath = DEFAULT_AUTH_PATH, spawn = spawnSync } = {}) {
|
|
102
140
|
const identity = repositoryPushIdentity(cwd, spawn)
|
|
103
|
-
validatePrPushAuthorization({ ...identity,
|
|
141
|
+
validatePrPushAuthorization({ ...identity, ...readPolicy(authPath) })
|
|
104
142
|
const destination = `HEAD:refs/heads/${identity.branch}`
|
|
105
143
|
// The helper runs outside Codex's workspace sandbox after the user authorizes
|
|
106
144
|
// it. Disable repository hooks so an edited pre-push hook cannot widen this
|
|
107
145
|
// one operation into arbitrary host execution.
|
|
108
146
|
const nullHooks = process.platform === 'win32' ? 'NUL' : '/dev/null'
|
|
109
|
-
|
|
147
|
+
// Pin the verified URL: `git push origin` may fan out to multiple pushurls.
|
|
148
|
+
const result = spawn('git', ['-c', `core.hooksPath=${nullHooks}`, 'push', '-u', identity.remote, destination], { cwd: identity.root, stdio: 'inherit' })
|
|
110
149
|
if (result.status !== 0) throw new Error(`git push failed with exit code ${result.status ?? 'unknown'}`)
|
|
111
150
|
return identity
|
|
112
151
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Bounded LRU of warm sessions, pinned while in use. Never evict an active
|
|
2
|
+
// session, and never share history between tickets or conversation threads.
|
|
3
|
+
export function createRunnerPool({ create, capacity = 8, idleMs = 300_000, now = Date.now }) {
|
|
4
|
+
const entries = new Map()
|
|
5
|
+
function prune() {
|
|
6
|
+
for (const [key, entry] of entries) {
|
|
7
|
+
if (!entry.busy && (now() - entry.usedAt >= idleMs || entries.size > capacity)) {
|
|
8
|
+
entry.runner.close?.()
|
|
9
|
+
entries.delete(key)
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
const timer = setInterval(prune, Math.min(idleMs, 30_000))
|
|
14
|
+
timer.unref?.()
|
|
15
|
+
return {
|
|
16
|
+
acquire(key, options) {
|
|
17
|
+
prune()
|
|
18
|
+
let entry = entries.get(key)
|
|
19
|
+
if (entry?.busy) throw new Error('Session already has an active cycle')
|
|
20
|
+
if (!entry) entry = { runner: create(options), usedAt: now(), busy: false }
|
|
21
|
+
entries.delete(key)
|
|
22
|
+
entry.busy = true
|
|
23
|
+
entries.set(key, entry)
|
|
24
|
+
prune()
|
|
25
|
+
return entry.runner
|
|
26
|
+
},
|
|
27
|
+
release(key) {
|
|
28
|
+
const entry = entries.get(key)
|
|
29
|
+
if (entry) { entry.busy = false; entry.usedAt = now() }
|
|
30
|
+
prune()
|
|
31
|
+
},
|
|
32
|
+
close() {
|
|
33
|
+
clearInterval(timer)
|
|
34
|
+
for (const entry of entries.values()) entry.runner.close?.()
|
|
35
|
+
entries.clear()
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export function createTaskTypeLookup({ load, now = Date.now, ttlMs = 60_000 }) {
|
|
2
|
+
const cache = new Map(), pending = new Map()
|
|
3
|
+
return async (projectId) => {
|
|
4
|
+
const key = String(projectId)
|
|
5
|
+
const hit = cache.get(key)
|
|
6
|
+
if (hit && hit.expiresAt > now()) return hit.value
|
|
7
|
+
if (pending.has(key)) return pending.get(key)
|
|
8
|
+
const request = Promise.resolve().then(() => load(projectId)).then((data) => {
|
|
9
|
+
const types = data.types ?? data.task_types ?? data.taskTypes
|
|
10
|
+
if (!Array.isArray(types)) throw new Error('Cannot verify task columns')
|
|
11
|
+
const matching = (pattern) => new Set(types.filter((type) => pattern.test(String(type.name || ''))).map((type) => Number(type.id)))
|
|
12
|
+
const value = {
|
|
13
|
+
done: matching(/\b(?:done|complete|completed|closed|cancelled|canceled|archived|resolved)\b/i),
|
|
14
|
+
review: matching(/\b(?:review|test|testing|qa|quality\s+assurance|verification|approval)\b/i),
|
|
15
|
+
}
|
|
16
|
+
cache.delete(key)
|
|
17
|
+
cache.set(key, { value, expiresAt: now() + ttlMs })
|
|
18
|
+
while (cache.size > 100) cache.delete(cache.keys().next().value)
|
|
19
|
+
return value
|
|
20
|
+
}).finally(() => pending.delete(key))
|
|
21
|
+
pending.set(key, request)
|
|
22
|
+
return request
|
|
23
|
+
}
|
|
24
|
+
}
|