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:
oliver 2026-09-14 20:48:15 +08:00
parent c26dc68f77
commit ad0cc98b38
17 changed files with 898 additions and 93 deletions

View 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)
}
}
}

View 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)
}

View 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)
}

View 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)

View 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)
})

View 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)
}