dsh-delete-session 0.2.1 → 0.2.2
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/package.json +1 -1
- package/src/index.js +25 -2
- package/test/artifacts.host.test.js +89 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-delete-session",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Delete sessions from the DSH Web sidebar: adds a Delete action to the session row menu with a risk-consent dialog, sends the session log, workspace accounting and live session state to the system trash (or removes them outright), and, when opted in, quarantines the files the conversation produced.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/index.js
CHANGED
|
@@ -117,6 +117,10 @@ function collectDescendants(headers, sessionId) {
|
|
|
117
117
|
const byParent = new Map()
|
|
118
118
|
for (const header of headers) {
|
|
119
119
|
if (typeof header.parentSession !== 'string') continue
|
|
120
|
+
// Only subagents belong to a parent. A fork records where it branched from,
|
|
121
|
+
// but it is a conversation of its own and the user can open it like any other:
|
|
122
|
+
// sweeping those up deletes work nobody asked to delete.
|
|
123
|
+
if (header.origin !== 'subagent') continue
|
|
120
124
|
const key = bareSessionId(header.parentSession)
|
|
121
125
|
const list = byParent.get(key)
|
|
122
126
|
if (list === undefined) byParent.set(key, [header])
|
|
@@ -144,7 +148,10 @@ function collectDescendants(headers, sessionId) {
|
|
|
144
148
|
*/
|
|
145
149
|
function collectOrphans(headers) {
|
|
146
150
|
const present = new Set(headers.map((entry) => bareSessionId(entry.id)))
|
|
147
|
-
|
|
151
|
+
// Same rule as the descendant walk: a fork whose source is gone is still a
|
|
152
|
+
// conversation of its own, not an abandoned child.
|
|
153
|
+
return headers.filter((entry) => entry.origin === 'subagent'
|
|
154
|
+
&& typeof entry.parentSession === 'string'
|
|
148
155
|
&& !present.has(bareSessionId(entry.parentSession)))
|
|
149
156
|
}
|
|
150
157
|
|
|
@@ -770,11 +777,27 @@ async function deleteSession(ctx, sessionId, options) {
|
|
|
770
777
|
const children = []
|
|
771
778
|
for (const childId of childSessionIds) {
|
|
772
779
|
try {
|
|
780
|
+
// A subagent can still be running while its parent is deleted. Removing the
|
|
781
|
+
// log out from under it is what made the agent's next write fail with ENOENT,
|
|
782
|
+
// so a child gets the same guard as the parent: stop it, flush it, and wait
|
|
783
|
+
// for the write lease before a single byte moves.
|
|
784
|
+
await quiesceAgent(ctx, childId)
|
|
785
|
+
await flushAndDetach(ctx, childId)
|
|
786
|
+
await assertNoActiveWriter(ctx, childId)
|
|
773
787
|
const childFiles = await retireSessionFiles(childId, recycle)
|
|
774
788
|
await withWorkspaceLock(() => detachFromWorkspace(ctx, childId))
|
|
775
789
|
children.push({ id: childId, removed: childFiles.removed, caches: childFiles.caches })
|
|
776
790
|
} catch (error) {
|
|
777
|
-
|
|
791
|
+
// Never delete a child that could not be quieted: something is still writing
|
|
792
|
+
// to it. Report it so the caller can try again once it settles.
|
|
793
|
+
const busy = error instanceof HttpError && error.code === 'busy'
|
|
794
|
+
children.push({
|
|
795
|
+
id: childId,
|
|
796
|
+
removed: 0,
|
|
797
|
+
caches: 0,
|
|
798
|
+
skipped: busy ? 'busy' : 'error',
|
|
799
|
+
error: String((error && error.message) || error),
|
|
800
|
+
})
|
|
778
801
|
}
|
|
779
802
|
}
|
|
780
803
|
|
|
@@ -954,7 +954,13 @@ test('the switch decides between staging for the bin and removing outright', asy
|
|
|
954
954
|
|
|
955
955
|
// --- subagent sessions are separate sessions, linked by parentSession ------
|
|
956
956
|
|
|
957
|
-
|
|
957
|
+
/** A session with no parent at all. */
|
|
958
|
+
const header = (id) => ({ id })
|
|
959
|
+
/** A real subagent: DSH marks these with `origin`, and they belong to their parent. */
|
|
960
|
+
const child = (id, parentSession) => ({ id, parentSession, origin: 'subagent', delegationDepth: 1 })
|
|
961
|
+
/** A fork: it records where it branched from, but it is a conversation of its own. */
|
|
962
|
+
const fork = (id, parentSession) => ({ id, parentSession, isSeeded: true, delegationDepth: 0 })
|
|
963
|
+
|
|
958
964
|
|
|
959
965
|
function fakeContext(services) {
|
|
960
966
|
return { get: (name) => services[name] }
|
|
@@ -963,8 +969,8 @@ function fakeContext(services) {
|
|
|
963
969
|
test('a delete finds the grandchildren behind a child, not just the children', () => {
|
|
964
970
|
const headers = [
|
|
965
971
|
header('session-parent'),
|
|
966
|
-
|
|
967
|
-
|
|
972
|
+
child('session-child', 'session-parent'),
|
|
973
|
+
child('session-grandchild', 'session-child'),
|
|
968
974
|
]
|
|
969
975
|
|
|
970
976
|
const found = internals.collectDescendants(headers, 'session-parent')
|
|
@@ -976,7 +982,7 @@ test('a delete finds the grandchildren behind a child, not just the children', (
|
|
|
976
982
|
test('a parent link matches whether or not either id carries the prefix', () => {
|
|
977
983
|
const headers = [
|
|
978
984
|
header('session-parent'),
|
|
979
|
-
|
|
985
|
+
child('child-uuid', 'parent-uuid'),
|
|
980
986
|
]
|
|
981
987
|
|
|
982
988
|
const found = internals.collectDescendants(headers, 'parent-uuid')
|
|
@@ -987,7 +993,7 @@ test('a parent link matches whether or not either id carries the prefix', () =>
|
|
|
987
993
|
test('a session that names itself as its parent does not loop forever', () => {
|
|
988
994
|
const headers = [
|
|
989
995
|
header('session-parent'),
|
|
990
|
-
|
|
996
|
+
child('session-child', 'session-child'),
|
|
991
997
|
]
|
|
992
998
|
|
|
993
999
|
const found = internals.collectDescendants(headers, 'session-parent')
|
|
@@ -995,11 +1001,36 @@ test('a session that names itself as its parent does not loop forever', () => {
|
|
|
995
1001
|
assert.deepEqual(found, [], 'a self-parented session is not its own descendant')
|
|
996
1002
|
})
|
|
997
1003
|
|
|
1004
|
+
test('a forked conversation is not a child of the conversation it branched from', () => {
|
|
1005
|
+
const headers = [
|
|
1006
|
+
header('session-parent'),
|
|
1007
|
+
fork('session-fork', 'session-parent'),
|
|
1008
|
+
]
|
|
1009
|
+
|
|
1010
|
+
const found = internals.collectDescendants(headers, 'session-parent')
|
|
1011
|
+
|
|
1012
|
+
assert.deepEqual(found, [], 'a fork is a visible conversation, not a subagent')
|
|
1013
|
+
})
|
|
1014
|
+
|
|
1015
|
+
test('a fork is never offered in the dialog, so it can never be ticked', () => {
|
|
1016
|
+
const headers = [
|
|
1017
|
+
header('session-parent'),
|
|
1018
|
+
fork('session-fork', 'session-parent'),
|
|
1019
|
+
child('session-real-child', 'session-parent'),
|
|
1020
|
+
]
|
|
1021
|
+
|
|
1022
|
+
const listed = internals.describeChildren(headers, 'session-parent')
|
|
1023
|
+
|
|
1024
|
+
assert.deepEqual(listed.map((c) => c.id), ['session-real-child'])
|
|
1025
|
+
const picked = internals.selectChildDeletes(headers, 'session-parent', ['session-fork', 'session-real-child'])
|
|
1026
|
+
assert.deepEqual(picked, ['session-real-child'], 'even a forged fork id is dropped')
|
|
1027
|
+
})
|
|
1028
|
+
|
|
998
1029
|
test('an orphan is a session whose parent is gone', () => {
|
|
999
1030
|
const headers = [
|
|
1000
1031
|
header('session-parent'),
|
|
1001
|
-
|
|
1002
|
-
|
|
1032
|
+
child('session-kept', 'session-parent'),
|
|
1033
|
+
child('session-orphan', 'session-deleted-long-ago'),
|
|
1003
1034
|
]
|
|
1004
1035
|
|
|
1005
1036
|
const orphans = internals.collectOrphans(headers)
|
|
@@ -1036,8 +1067,8 @@ test('a host that can list nothing yields no headers rather than throwing', asyn
|
|
|
1036
1067
|
test('the child list carries a depth so nesting is visible in the dialog', () => {
|
|
1037
1068
|
const headers = [
|
|
1038
1069
|
header('session-parent'),
|
|
1039
|
-
{
|
|
1040
|
-
{
|
|
1070
|
+
{ ...child('session-child', 'session-parent'), cwd: 'E:\\work' },
|
|
1071
|
+
{ ...child('session-grandchild', 'session-child'), cwd: 'E:\\work' },
|
|
1041
1072
|
]
|
|
1042
1073
|
|
|
1043
1074
|
const children = internals.describeChildren(headers, 'session-parent')
|
|
@@ -1051,8 +1082,8 @@ test('the child list carries a depth so nesting is visible in the dialog', () =>
|
|
|
1051
1082
|
test('only the ids the user picked, and only real descendants, are deleted', () => {
|
|
1052
1083
|
const headers = [
|
|
1053
1084
|
header('session-parent'),
|
|
1054
|
-
|
|
1055
|
-
|
|
1085
|
+
child('session-child', 'session-parent'),
|
|
1086
|
+
child('session-grandchild', 'session-child'),
|
|
1056
1087
|
header('session-unrelated'),
|
|
1057
1088
|
]
|
|
1058
1089
|
|
|
@@ -1068,8 +1099,8 @@ test('only the ids the user picked, and only real descendants, are deleted', ()
|
|
|
1068
1099
|
test('a picked child is matched by its bare uuid but deleted under its own id', () => {
|
|
1069
1100
|
const headers = [
|
|
1070
1101
|
header('session-parent'),
|
|
1071
|
-
|
|
1072
|
-
|
|
1102
|
+
child('session-grandchild', 'session-child'),
|
|
1103
|
+
child('session-child', 'session-parent'),
|
|
1073
1104
|
]
|
|
1074
1105
|
|
|
1075
1106
|
// The dialog may hold either spelling; the delete always uses the header's own.
|
|
@@ -1139,3 +1170,48 @@ test('the delete reports which children failed', async () => {
|
|
|
1139
1170
|
|
|
1140
1171
|
assert.equal(result.children.length, 0, 'an invalid id never becomes a child delete')
|
|
1141
1172
|
})
|
|
1173
|
+
|
|
1174
|
+
test('a child is probed for a live writer before its files go', async () => {
|
|
1175
|
+
const parent = plantSession(PARENT_ID)
|
|
1176
|
+
const child = plantSession(CHILD_ID)
|
|
1177
|
+
const probed = []
|
|
1178
|
+
|
|
1179
|
+
// A host service that answers the write-lease probe for whoever asks.
|
|
1180
|
+
const ctx = {
|
|
1181
|
+
get: (name) => (name === 'sessionPersistence'
|
|
1182
|
+
? {
|
|
1183
|
+
open: async (id) => { probed.push(id); return { close: async () => {} } },
|
|
1184
|
+
stat: async () => undefined,
|
|
1185
|
+
}
|
|
1186
|
+
: undefined),
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
await internals.deleteSession(ctx, PARENT_ID, { recycle: false, childSessionIds: [CHILD_ID] })
|
|
1190
|
+
|
|
1191
|
+
assert.ok(probed.includes(CHILD_ID), 'the child gets the same write-lease probe the parent gets')
|
|
1192
|
+
assert.equal(fs.existsSync(child.dir), false, 'and it is deleted once the probe comes back clear')
|
|
1193
|
+
})
|
|
1194
|
+
|
|
1195
|
+
test('a child that is still open is left alone and reported as skipped', async () => {
|
|
1196
|
+
const parent = plantSession(PARENT_ID)
|
|
1197
|
+
const child = plantSession(CHILD_ID)
|
|
1198
|
+
|
|
1199
|
+
// Busy on every attempt: the guard gives up and the child must survive.
|
|
1200
|
+
const ctx = {
|
|
1201
|
+
get: (name) => (name === 'sessionPersistence'
|
|
1202
|
+
? {
|
|
1203
|
+
open: async (id) => {
|
|
1204
|
+
if (id === CHILD_ID) throw new Error('session is busy: another writer holds it')
|
|
1205
|
+
return { close: async () => {} }
|
|
1206
|
+
},
|
|
1207
|
+
stat: async () => undefined,
|
|
1208
|
+
}
|
|
1209
|
+
: undefined),
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
const result = await internals.deleteSession(ctx, PARENT_ID, { recycle: false, childSessionIds: [CHILD_ID] })
|
|
1213
|
+
|
|
1214
|
+
assert.equal(fs.existsSync(parent.dir), false, 'the parent still goes')
|
|
1215
|
+
assert.equal(fs.existsSync(child.dir), true, 'a busy child is not deleted under its writer')
|
|
1216
|
+
assert.equal(result.children[0].skipped, 'busy', 'and the report says why')
|
|
1217
|
+
})
|