openvisio-agent 0.19.10 → 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 +22 -17
- 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 +228 -492
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
|
+
}
|