mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-10 11:33:15 +08:00
Make session visibility preference-driven and keep system-channel ACL separate.
Super/fallback can toggle view-all (default on); admin/user stay own-only while still seeing unowned system sessions. Add restore/prune helpers so empty-shell cleanup is explicit and recoverable.
This commit is contained in:
parent
c26dc68f77
commit
ad0cc98b38
17 changed files with 898 additions and 93 deletions
48
uds-auth/scripts/check-session-registry.ps1
Normal file
48
uds-auth/scripts/check-session-registry.ps1
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
$ErrorActionPreference = 'Stop'
|
||||
$w = Get-Content 'C:\Users\zhout\.dsh\storages\workspace.json' -Raw | ConvertFrom-Json
|
||||
$archived = [System.Collections.Generic.HashSet[string]]::new([string[]]@($w.global.archivedSessionIds))
|
||||
Write-Output ("archived count=" + $archived.Count)
|
||||
|
||||
$liveIds = New-Object System.Collections.Generic.List[string]
|
||||
Get-ChildItem 'C:\Users\zhout\.dsh\sessions' -Directory | ForEach-Object {
|
||||
Get-ChildItem $_.FullName -Directory | ForEach-Object { [void]$liveIds.Add($_.Name) }
|
||||
}
|
||||
Write-Output ("live session dirs=" + $liveIds.Count)
|
||||
$inArchived = @($liveIds | Where-Object { $archived.Contains($_) })
|
||||
Write-Output ("live dirs that are archived=" + $inArchived.Count)
|
||||
if ($inArchived.Count -gt 0 -and $inArchived.Count -le 20) {
|
||||
$inArchived | ForEach-Object { Write-Output (" archived: " + $_) }
|
||||
}
|
||||
|
||||
Write-Output '--- table keys ---'
|
||||
foreach ($prop in $w.tables.PSObject.Properties) {
|
||||
$name = $prop.Name
|
||||
$val = $prop.Value
|
||||
if ($null -eq $val) {
|
||||
Write-Output (" {0}=null" -f $name)
|
||||
continue
|
||||
}
|
||||
if ($val -is [System.Array] -or ($val -is [System.Collections.IList])) {
|
||||
Write-Output (" {0} list count={1}" -f $name, @($val).Count)
|
||||
continue
|
||||
}
|
||||
if ($val.PSObject -and $val.PSObject.Properties['rows']) {
|
||||
Write-Output (" {0}.rows={1}" -f $name, @($val.rows).Count)
|
||||
continue
|
||||
}
|
||||
# workspace records often keyed by id
|
||||
$keys = @($val.PSObject.Properties.Name)
|
||||
Write-Output (" {0} keys={1} sample={2}" -f $name, $keys.Count, (($keys | Select-Object -First 3) -join ','))
|
||||
foreach ($k in ($keys | Select-Object -First 3)) {
|
||||
$row = $val.$k
|
||||
if ($row.sessionIds) {
|
||||
Write-Output (" {0} sessionIds={1}" -f $k, @($row.sessionIds).Count)
|
||||
} elseif ($row.PSObject.Properties['sessionIds']) {
|
||||
Write-Output (" {0} sessionIds={1}" -f $k, @($row.sessionIds).Count)
|
||||
} else {
|
||||
$rowJson = ($row | ConvertTo-Json -Compress -Depth 3)
|
||||
if ($rowJson.Length -gt 200) { $rowJson = $rowJson.Substring(0, 200) + '...' }
|
||||
Write-Output (" {0} => {1}" -f $k, $rowJson)
|
||||
}
|
||||
}
|
||||
}
|
||||
14
uds-auth/scripts/count-sessions.ps1
Normal file
14
uds-auth/scripts/count-sessions.ps1
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
$live = 'C:\Users\zhout\.dsh\sessions'
|
||||
$bak = 'C:\Users\zhout\.dsh\upgrade-backup-20260914-074633\sessions'
|
||||
|
||||
Write-Output 'LIVE:'
|
||||
Get-ChildItem $live -Directory -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
$count = @(Get-ChildItem $_.FullName -Directory -ErrorAction SilentlyContinue).Count
|
||||
Write-Output (" {0} => {1}" -f $_.Name, $count)
|
||||
}
|
||||
|
||||
Write-Output 'BACKUP:'
|
||||
Get-ChildItem $bak -Directory -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
$count = @(Get-ChildItem $_.FullName -Directory -ErrorAction SilentlyContinue).Count
|
||||
Write-Output (" {0} => {1}" -f $_.Name, $count)
|
||||
}
|
||||
9
uds-auth/scripts/dump-workspaces.ps1
Normal file
9
uds-auth/scripts/dump-workspaces.ps1
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
$ErrorActionPreference = 'Stop'
|
||||
$w = Get-Content 'C:\Users\zhout\.dsh\storages\workspace.json' -Raw | ConvertFrom-Json
|
||||
foreach ($prop in $w.tables.workspaces.PSObject.Properties) {
|
||||
$row = $prop.Value
|
||||
Write-Output ("id={0}" -f $prop.Name)
|
||||
Write-Output (" path={0}" -f $row.path)
|
||||
Write-Output (" title={0}" -f $row.title)
|
||||
Write-Output (" sessionIds={0}" -f @($row.sessionIds).Count)
|
||||
}
|
||||
44
uds-auth/scripts/merge-sessions-into-workspace.cjs
Normal file
44
uds-auth/scripts/merge-sessions-into-workspace.cjs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const workspacePath = 'C:/Users/zhout/.dsh/storages/workspace.json'
|
||||
const sessionsRoot = 'C:/Users/zhout/.dsh/sessions'
|
||||
|
||||
const map = {
|
||||
'--C-Users-zhout-.dsh-user-workspaces-administrator--':
|
||||
'C:\\Users\\zhout\\.dsh\\user-workspaces\\administrator',
|
||||
'--D-project-chatgpt--': 'D:\\project\\chatgpt',
|
||||
'--D-project-harness--': 'D:\\project\\harness',
|
||||
}
|
||||
|
||||
const w = JSON.parse(fs.readFileSync(workspacePath, 'utf8'))
|
||||
const pathToId = {}
|
||||
for (const [id, row] of Object.entries(w.tables.workspaces)) {
|
||||
pathToId[row.path] = id
|
||||
}
|
||||
|
||||
let added = 0
|
||||
for (const [folder, wsPath] of Object.entries(map)) {
|
||||
const wsId = pathToId[wsPath]
|
||||
if (!wsId) {
|
||||
console.log('skip no workspace', wsPath)
|
||||
continue
|
||||
}
|
||||
const proj = path.join(sessionsRoot, folder)
|
||||
if (!fs.existsSync(proj)) continue
|
||||
const disk = fs
|
||||
.readdirSync(proj, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory())
|
||||
.map((d) => d.name)
|
||||
const row = w.tables.workspaces[wsId]
|
||||
const existing = new Set(row.sessionIds || [])
|
||||
const missing = disk.filter((id) => !existing.has(id))
|
||||
added += missing.length
|
||||
row.sessionIds = [...missing, ...(row.sessionIds || [])]
|
||||
row.updatedAt = new Date().toISOString()
|
||||
console.log(wsPath, 'disk', disk.length, 'now', row.sessionIds.length, 'added', missing.length)
|
||||
}
|
||||
|
||||
fs.copyFileSync(workspacePath, workspacePath + '.bak-before-session-restore')
|
||||
fs.writeFileSync(workspacePath, JSON.stringify(w, null, 2) + '\n')
|
||||
console.log('added', added)
|
||||
250
uds-auth/scripts/prune-empty-sessions.cjs
Normal file
250
uds-auth/scripts/prune-empty-sessions.cjs
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
/**
|
||||
* Classify & prune empty/invalid DSH sessions under ~/.dsh/sessions.
|
||||
*
|
||||
* empty/shell — log exists but has no user/message and no turn/start
|
||||
* (only session header + permission/sandbox/approval/end-seed)
|
||||
* invalid — no log, 0 bytes, corrupt, non-session header
|
||||
* keep — has at least one conversational event
|
||||
*
|
||||
* Usage:
|
||||
* node prune-empty-sessions.cjs --dry-run
|
||||
* node prune-empty-sessions.cjs --apply
|
||||
*/
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { promisify } = require('util')
|
||||
const { zstdDecompress } = require('zlib')
|
||||
const zstdDecompressAsync = promisify(zstdDecompress)
|
||||
|
||||
const SESSIONS_ROOT = 'C:/Users/zhout/.dsh/sessions'
|
||||
const WORKSPACE_PATH = 'C:/Users/zhout/.dsh/storages/workspace.json'
|
||||
const OWNERS_PATH = 'D:/project/chatgpt/oclaw/uds-auth/session-owners.json'
|
||||
const PROJCACHE = 'C:/Users/zhout/.dsh/storages/session_projcache/sessions'
|
||||
|
||||
const ZSTD_MAGIC = 0xfd2fb528
|
||||
const apply = process.argv.includes('--apply')
|
||||
|
||||
/** Event types that prove the session had real conversation activity. */
|
||||
const LIVE_TYPES = new Set([
|
||||
'user/message',
|
||||
'turn/start',
|
||||
'assistant/message',
|
||||
'agent/message',
|
||||
'step/start',
|
||||
])
|
||||
|
||||
function scanZstdFrames(buffer, maxFrames = Infinity) {
|
||||
const frames = []
|
||||
let offset = 0
|
||||
while (offset < buffer.length) {
|
||||
const start = offset
|
||||
if (buffer.length - offset < 4) return { frames, tornStart: start }
|
||||
if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC) {
|
||||
throw new Error(`invalid magic at ${offset}`)
|
||||
}
|
||||
offset += 4
|
||||
if (offset === buffer.length) return { frames, tornStart: start }
|
||||
const descriptor = buffer.readUInt8(offset)
|
||||
offset += 1
|
||||
if ((descriptor & 0x18) !== 0) throw new Error('reserved frame-header bit')
|
||||
const contentSizeFlag = descriptor >>> 6
|
||||
const singleSegment = (descriptor & 0x20) !== 0
|
||||
const checksum = (descriptor & 0x04) !== 0
|
||||
const dictionaryFlag = descriptor & 0x03
|
||||
const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag
|
||||
const contentSizeBytes =
|
||||
contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag
|
||||
const remainingHeaderBytes =
|
||||
(singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes
|
||||
if (buffer.length - offset < remainingHeaderBytes) return { frames, tornStart: start }
|
||||
offset += remainingHeaderBytes
|
||||
for (;;) {
|
||||
if (buffer.length - offset < 3) return { frames, tornStart: start }
|
||||
const blockHeader = buffer.readUIntLE(offset, 3)
|
||||
offset += 3
|
||||
const lastBlock = (blockHeader & 1) !== 0
|
||||
const blockType = (blockHeader >>> 1) & 0x03
|
||||
const blockSize = blockHeader >>> 3
|
||||
if (blockType === 0x03) throw new Error('reserved block type')
|
||||
const payloadBytes = blockType === 0x01 ? 1 : blockSize
|
||||
if (buffer.length - offset < payloadBytes) return { frames, tornStart: start }
|
||||
offset += payloadBytes
|
||||
if (lastBlock) break
|
||||
}
|
||||
if (checksum) {
|
||||
if (buffer.length - offset < 4) return { frames, tornStart: start }
|
||||
offset += 4
|
||||
}
|
||||
frames.push({ start, end: offset })
|
||||
if (frames.length >= maxFrames) return { frames }
|
||||
}
|
||||
return { frames }
|
||||
}
|
||||
|
||||
function findLog(dir) {
|
||||
const names = fs.readdirSync(dir)
|
||||
const preferred = names
|
||||
.filter((n) => /^session(\.v\d+)?\.jsonl(\.zstd)?$/.test(n))
|
||||
.sort()
|
||||
return preferred[0] ? path.join(dir, preferred[0]) : null
|
||||
}
|
||||
|
||||
function collectTypesFromText(text, types) {
|
||||
for (const line of text.split(/\n/)) {
|
||||
if (!line.trim()) continue
|
||||
try {
|
||||
const o = JSON.parse(line)
|
||||
if (o && typeof o.type === 'string') types.add(o.type)
|
||||
} catch {
|
||||
// ignore bad lines
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function classify(dir) {
|
||||
const log = findLog(dir)
|
||||
if (!log) return { kind: 'invalid', reason: 'no-log' }
|
||||
const buf = fs.readFileSync(log)
|
||||
if (buf.length === 0) return { kind: 'invalid', reason: 'zero-bytes' }
|
||||
|
||||
const types = new Set()
|
||||
try {
|
||||
if (log.endsWith('.zstd')) {
|
||||
const { frames, tornStart } = scanZstdFrames(buf)
|
||||
if (frames.length === 0) {
|
||||
return { kind: 'invalid', reason: tornStart != null ? 'torn-frame' : 'no-frames' }
|
||||
}
|
||||
for (const fr of frames) {
|
||||
const plain = await zstdDecompressAsync(buf.subarray(fr.start, fr.end))
|
||||
collectTypesFromText(plain.toString('utf8'), types)
|
||||
// Early exit once we know it's live
|
||||
for (const t of LIVE_TYPES) {
|
||||
if (types.has(t)) {
|
||||
return { kind: 'keep', reason: `has:${t}`, types: [...types] }
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
collectTypesFromText(buf.toString('utf8'), types)
|
||||
}
|
||||
} catch (e) {
|
||||
return { kind: 'invalid', reason: `decode:${e.message}` }
|
||||
}
|
||||
|
||||
if (!types.has('session')) {
|
||||
return { kind: 'invalid', reason: 'non-session-header', types: [...types] }
|
||||
}
|
||||
|
||||
for (const t of LIVE_TYPES) {
|
||||
if (types.has(t)) return { kind: 'keep', reason: `has:${t}`, types: [...types] }
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'empty',
|
||||
reason: 'no-conversation',
|
||||
types: [...types],
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const results = { invalid: [], empty: [], keep: [] }
|
||||
|
||||
for (const proj of fs.readdirSync(SESSIONS_ROOT, { withFileTypes: true })) {
|
||||
if (!proj.isDirectory()) continue
|
||||
const projDir = path.join(SESSIONS_ROOT, proj.name)
|
||||
for (const sid of fs.readdirSync(projDir, { withFileTypes: true })) {
|
||||
if (!sid.isDirectory()) continue
|
||||
const dir = path.join(projDir, sid.name)
|
||||
const c = await classify(dir)
|
||||
results[c.kind].push({
|
||||
id: sid.name,
|
||||
proj: proj.name,
|
||||
dir,
|
||||
reason: c.reason,
|
||||
types: c.types,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const byProj = {}
|
||||
for (const item of [...results.empty, ...results.invalid]) {
|
||||
byProj[item.proj] = (byProj[item.proj] || 0) + 1
|
||||
}
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
mode: apply ? 'apply' : 'dry-run',
|
||||
counts: {
|
||||
invalid: results.invalid.length,
|
||||
empty: results.empty.length,
|
||||
keep: results.keep.length,
|
||||
deleteTotal: results.invalid.length + results.empty.length,
|
||||
},
|
||||
deleteByProject: byProj,
|
||||
sampleEmpty: results.empty.slice(0, 5).map((x) => ({
|
||||
id: x.id,
|
||||
reason: x.reason,
|
||||
types: x.types,
|
||||
})),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
|
||||
if (!apply) {
|
||||
console.log('\nRe-run with --apply to delete invalid+empty and update workspace/owners.')
|
||||
return
|
||||
}
|
||||
|
||||
const toDelete = [...results.invalid, ...results.empty]
|
||||
const deleteIds = new Set(toDelete.map((x) => x.id))
|
||||
|
||||
for (const item of toDelete) {
|
||||
fs.rmSync(item.dir, { recursive: true, force: true })
|
||||
const cache = path.join(PROJCACHE, `${item.id}.json`)
|
||||
if (fs.existsSync(cache)) fs.rmSync(cache, { force: true })
|
||||
}
|
||||
|
||||
if (fs.existsSync(WORKSPACE_PATH)) {
|
||||
const bak = WORKSPACE_PATH + '.bak-before-prune-empty'
|
||||
fs.copyFileSync(WORKSPACE_PATH, bak)
|
||||
const w = JSON.parse(fs.readFileSync(WORKSPACE_PATH, 'utf8'))
|
||||
if (Array.isArray(w.global?.archivedSessionIds)) {
|
||||
w.global.archivedSessionIds = w.global.archivedSessionIds.filter((id) => !deleteIds.has(id))
|
||||
}
|
||||
for (const row of Object.values(w.tables?.workspaces || {})) {
|
||||
if (!Array.isArray(row.sessionIds)) continue
|
||||
const before = row.sessionIds.length
|
||||
row.sessionIds = row.sessionIds.filter((id) => !deleteIds.has(id))
|
||||
if (row.sessionIds.length !== before) row.updatedAt = new Date().toISOString()
|
||||
}
|
||||
fs.writeFileSync(WORKSPACE_PATH, JSON.stringify(w, null, 2) + '\n')
|
||||
console.log('updated workspace.json; backup', bak)
|
||||
}
|
||||
|
||||
if (fs.existsSync(OWNERS_PATH)) {
|
||||
const bak = OWNERS_PATH + '.bak-before-prune-empty'
|
||||
fs.copyFileSync(OWNERS_PATH, bak)
|
||||
const o = JSON.parse(fs.readFileSync(OWNERS_PATH, 'utf8'))
|
||||
let removed = 0
|
||||
if (o.owners && typeof o.owners === 'object') {
|
||||
for (const id of deleteIds) {
|
||||
if (Object.prototype.hasOwnProperty.call(o.owners, id)) {
|
||||
delete o.owners[id]
|
||||
removed++
|
||||
}
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(OWNERS_PATH, JSON.stringify(o, null, 2) + '\n')
|
||||
console.log('updated session-owners.json removed', removed, 'backup', bak)
|
||||
}
|
||||
|
||||
console.log('deleted dirs', toDelete.length)
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
35
uds-auth/scripts/restore-sessions-from-backup.ps1
Normal file
35
uds-auth/scripts/restore-sessions-from-backup.ps1
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
$ErrorActionPreference = 'Stop'
|
||||
$srcRoot = 'C:\Users\zhout\.dsh\upgrade-backup-20260914-074633\sessions'
|
||||
$dstRoot = 'C:\Users\zhout\.dsh\sessions'
|
||||
|
||||
if (-not (Test-Path $srcRoot)) { throw "backup missing: $srcRoot" }
|
||||
if (-not (Test-Path $dstRoot)) { New-Item -ItemType Directory -Path $dstRoot | Out-Null }
|
||||
|
||||
$copied = 0
|
||||
$skipped = 0
|
||||
|
||||
Get-ChildItem $srcRoot -Directory | ForEach-Object {
|
||||
$proj = $_.Name
|
||||
$srcProj = $_.FullName
|
||||
$dstProj = Join-Path $dstRoot $proj
|
||||
if (-not (Test-Path $dstProj)) {
|
||||
New-Item -ItemType Directory -Path $dstProj | Out-Null
|
||||
}
|
||||
Get-ChildItem $srcProj -Directory | ForEach-Object {
|
||||
$sid = $_.Name
|
||||
$dstSid = Join-Path $dstProj $sid
|
||||
if (Test-Path $dstSid) {
|
||||
$skipped++
|
||||
return
|
||||
}
|
||||
Copy-Item -LiteralPath $_.FullName -Destination $dstSid -Recurse -Force
|
||||
$copied++
|
||||
}
|
||||
}
|
||||
|
||||
Write-Output ("copied={0} skipped_existing={1}" -f $copied, $skipped)
|
||||
Write-Output 'LIVE after restore:'
|
||||
Get-ChildItem $dstRoot -Directory | ForEach-Object {
|
||||
$count = @(Get-ChildItem $_.FullName -Directory -ErrorAction SilentlyContinue).Count
|
||||
Write-Output (" {0} => {1}" -f $_.Name, $count)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue