@amaster.ai/dsh-a2a 0.1.37 → 0.1.39
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 +50 -12
- package/lib/bridge.js +32 -9
- package/lib/content.js +139 -96
- package/lib/executor.js +4 -3
- package/lib/inbox-api.js +3 -6
- package/lib/inbox.js +51 -40
- package/lib/stores/filesystem.js +40 -15
- package/lib/stores/gcs.js +61 -15
- package/lib/stores/listing.js +63 -0
- package/lib/stores/redis.js +55 -12
- package/lib/task-store.js +41 -7
- package/lib/types/bridge.d.ts +3 -2
- package/lib/types/content.d.ts +2 -1
- package/lib/types/inbox.d.ts +8 -5
- package/lib/types/stores/filesystem.d.ts +2 -0
- package/lib/types/stores/gcs.d.ts +2 -0
- package/lib/types/stores/listing.d.ts +10 -0
- package/lib/types/stores/redis.d.ts +2 -0
- package/lib/types/task-store.d.ts +8 -0
- package/package.json +1 -1
package/lib/inbox.js
CHANGED
|
@@ -5,7 +5,7 @@ import { RequestMalformedError } from '@a2a-js/sdk/errors';
|
|
|
5
5
|
import { AgentEvent, ServerCallContext, STATE_HEADERS_KEY, } from '@a2a-js/sdk/server';
|
|
6
6
|
import { createUserMessage } from '@deepseek-ai/dsh-llm/message';
|
|
7
7
|
import { InboxChanges } from './inbox-changes.js';
|
|
8
|
-
import { commitInboxInput, markInboxInputPending } from './task-store.js';
|
|
8
|
+
import { commitInboxInput, findRecoveryCandidates, isTerminalTask, markInboxInputPending, taskScopeKey, } from './task-store.js';
|
|
9
9
|
import { agentTextMessage, SessionTranslator, terminalStatusUpdate } from './translator.js';
|
|
10
10
|
export const INBOX_EXTENSION = 'https://github.com/TGYD-helige/dsh-plugins/blob/main/packages/dsh-a2a/README.md';
|
|
11
11
|
const RETAINED_INPUTS = `${INBOX_EXTENSION}#retained-inputs`;
|
|
@@ -131,11 +131,14 @@ export class NativeInbox {
|
|
|
131
131
|
fiber = parent;
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
|
-
content(message, contextId) {
|
|
135
|
-
return this.bridge.buildContent(message, contextId);
|
|
134
|
+
content(message, contextId, signal) {
|
|
135
|
+
return this.bridge.buildContent(message, contextId, signal);
|
|
136
136
|
}
|
|
137
|
-
async resolve(contextId) {
|
|
138
|
-
return (await this.context(contextId)).entry.handle.agent;
|
|
137
|
+
async resolve(contextId, context) {
|
|
138
|
+
return (await this.context(contextId, context)).entry.handle.agent;
|
|
139
|
+
}
|
|
140
|
+
recoveryCandidates(context = new ServerCallContext()) {
|
|
141
|
+
return findRecoveryCandidates(this.store, context);
|
|
139
142
|
}
|
|
140
143
|
async get(contextId, page) {
|
|
141
144
|
validateInboxPage(page);
|
|
@@ -255,7 +258,7 @@ export class NativeInbox {
|
|
|
255
258
|
!message.parts.length ||
|
|
256
259
|
message.parts.some((part) => !part.content))
|
|
257
260
|
throw new RequestMalformedError('A user Message with unchanged messageId, taskId and contextId is required.');
|
|
258
|
-
const content = await this.content(message, contextId);
|
|
261
|
+
const content = await this.content(message, contextId, task.controller.signal);
|
|
259
262
|
if (this.stopping ||
|
|
260
263
|
this.bridge.isClearing(contextId) ||
|
|
261
264
|
this.queued(run, id).message !== queued.message)
|
|
@@ -523,7 +526,7 @@ export class NativeInbox {
|
|
|
523
526
|
if (this.stopping || this.bridge.isClearing(contextId))
|
|
524
527
|
throw new Error('Context is unavailable.');
|
|
525
528
|
const previous = await this.store.load(taskId, context);
|
|
526
|
-
if (!previous || previous.contextId !== contextId || !
|
|
529
|
+
if (!previous || previous.contextId !== contextId || !isTerminalTask(previous))
|
|
527
530
|
throw new Error('Resume requires a terminal retained task.');
|
|
528
531
|
const retained = retainedSnapshot(previous);
|
|
529
532
|
if (typeof retained !== 'string')
|
|
@@ -658,7 +661,7 @@ export class NativeInbox {
|
|
|
658
661
|
if (message.source.promotedFrom === message.source.taskId)
|
|
659
662
|
continue;
|
|
660
663
|
const former = await this.store.load(message.source.promotedFrom, context);
|
|
661
|
-
if (!former ||
|
|
664
|
+
if (!former || isTerminalTask(former))
|
|
662
665
|
continue;
|
|
663
666
|
former.status = {
|
|
664
667
|
state: TaskState.TASK_STATE_CANCELED,
|
|
@@ -694,7 +697,7 @@ export class NativeInbox {
|
|
|
694
697
|
continue;
|
|
695
698
|
// Terminal snapshots belong to explicit resume(); do not consume their
|
|
696
699
|
// retained metadata during ordinary context recovery.
|
|
697
|
-
if (
|
|
700
|
+
if (isTerminalTask(task))
|
|
698
701
|
continue;
|
|
699
702
|
snapshots.push(task);
|
|
700
703
|
for (const input of JSON.parse(retained)) {
|
|
@@ -797,18 +800,25 @@ export class NativeInbox {
|
|
|
797
800
|
await this.checkpoint(task, context);
|
|
798
801
|
}
|
|
799
802
|
}
|
|
800
|
-
context(contextId, context
|
|
803
|
+
context(contextId, context) {
|
|
801
804
|
if (this.stopping)
|
|
802
805
|
return Promise.reject(new Error('Native inbox is unloading.'));
|
|
806
|
+
const checkScope = (run) => {
|
|
807
|
+
if (context && run.scope !== taskScopeKey(context))
|
|
808
|
+
throw new Error('Context is already loaded in a different tenant or owner scope.');
|
|
809
|
+
return run;
|
|
810
|
+
};
|
|
803
811
|
const existing = this.contexts.get(contextId);
|
|
804
812
|
if (existing)
|
|
805
|
-
return Promise.resolve(existing);
|
|
813
|
+
return Promise.resolve(existing).then(checkScope);
|
|
806
814
|
let creating = this.resolving.get(contextId);
|
|
807
815
|
if (!creating) {
|
|
808
816
|
creating = (async () => {
|
|
817
|
+
const call = context ?? new ServerCallContext();
|
|
809
818
|
const { entry } = await this.bridge.ensureTask(randomUUID(), contextId);
|
|
810
819
|
const run = {
|
|
811
820
|
entry,
|
|
821
|
+
scope: taskScopeKey(call),
|
|
812
822
|
paused: false,
|
|
813
823
|
tasks: new Map(),
|
|
814
824
|
claimed: new Set(),
|
|
@@ -819,23 +829,26 @@ export class NativeInbox {
|
|
|
819
829
|
rolledBack: new Set(),
|
|
820
830
|
retained: { 'next-step': [], 'next-turn': [] },
|
|
821
831
|
};
|
|
822
|
-
await this.recoverRetained(run,
|
|
823
|
-
await this.recoverPromotions(run,
|
|
832
|
+
await this.recoverRetained(run, call);
|
|
833
|
+
await this.recoverPromotions(run, call);
|
|
824
834
|
const snapshots = new Map();
|
|
825
835
|
for (const message of this.pending(run)) {
|
|
826
836
|
if (message.source.kind !== 'a2a' || snapshots.has(message.source.taskId))
|
|
827
837
|
continue;
|
|
828
|
-
const snapshot = await this.store.load(message.source.taskId,
|
|
838
|
+
const snapshot = await this.store.load(message.source.taskId, call);
|
|
829
839
|
if (!snapshot || snapshot.contextId !== contextId)
|
|
830
840
|
throw new Error('Queued A2A task is not accessible.');
|
|
831
|
-
if (
|
|
841
|
+
if (isTerminalTask(snapshot))
|
|
832
842
|
entry.handle.agent.inbox.remove(message.id);
|
|
833
843
|
else
|
|
834
844
|
snapshots.set(snapshot.id, snapshot);
|
|
835
845
|
}
|
|
836
846
|
await this.flush(entry.handle.agent.session);
|
|
847
|
+
const committed = snapshots.size
|
|
848
|
+
? this.committedInputs(run, new Set(snapshots.keys()))
|
|
849
|
+
: new Map();
|
|
837
850
|
for (const snapshot of snapshots.values())
|
|
838
|
-
this.restoreTask(run, snapshot,
|
|
851
|
+
this.restoreTask(run, snapshot, call, committed.get(snapshot.id) ?? []);
|
|
839
852
|
run.retained = structuredClone({
|
|
840
853
|
'next-step': [...entry.handle.agent.inbox.nextStep],
|
|
841
854
|
'next-turn': [...entry.handle.agent.inbox.nextTurn],
|
|
@@ -848,7 +861,7 @@ export class NativeInbox {
|
|
|
848
861
|
})().finally(() => this.resolving.delete(contextId));
|
|
849
862
|
this.resolving.set(contextId, creating);
|
|
850
863
|
}
|
|
851
|
-
return creating;
|
|
864
|
+
return creating.then(checkScope);
|
|
852
865
|
}
|
|
853
866
|
observe(phase, run, fields = {}) {
|
|
854
867
|
try {
|
|
@@ -951,12 +964,10 @@ export class NativeInbox {
|
|
|
951
964
|
context: new ServerCallContext({ tenant: context.tenant, user: context.user }),
|
|
952
965
|
};
|
|
953
966
|
}
|
|
954
|
-
controls(message
|
|
967
|
+
controls(message) {
|
|
955
968
|
const value = message.metadata?.[INBOX_EXTENSION];
|
|
956
969
|
if (value === undefined)
|
|
957
970
|
return { target: 'next-turn', wakeup: true };
|
|
958
|
-
if (!context.requestedExtensions?.includes(INBOX_EXTENSION))
|
|
959
|
-
throw new Error('Inbox controls require A2A-Extensions negotiation.');
|
|
960
971
|
if (!value ||
|
|
961
972
|
typeof value !== 'object' ||
|
|
962
973
|
Array.isArray(value) ||
|
|
@@ -966,7 +977,6 @@ export class NativeInbox {
|
|
|
966
977
|
throw new Error('Invalid inbox target.');
|
|
967
978
|
if (value.wakeup !== undefined && typeof value.wakeup !== 'boolean')
|
|
968
979
|
throw new Error('Invalid inbox wakeup.');
|
|
969
|
-
context.addActivatedExtension(INBOX_EXTENSION);
|
|
970
980
|
return {
|
|
971
981
|
target: value.target === 'next-step' ? 'next-step' : 'next-turn',
|
|
972
982
|
wakeup: value.wakeup !== false,
|
|
@@ -976,7 +986,7 @@ export class NativeInbox {
|
|
|
976
986
|
async beforeMessage(message, context) {
|
|
977
987
|
if (!message.messageId)
|
|
978
988
|
throw new Error('message.messageId is required.');
|
|
979
|
-
const controls = this.controls(message
|
|
989
|
+
const controls = this.controls(message);
|
|
980
990
|
if (!message.taskId) {
|
|
981
991
|
if (controls.target === 'next-step')
|
|
982
992
|
throw new Error('Steering must address an active task.');
|
|
@@ -985,7 +995,7 @@ export class NativeInbox {
|
|
|
985
995
|
const snapshot = await this.store.load(message.taskId, context);
|
|
986
996
|
if (!snapshot ||
|
|
987
997
|
(message.contextId && snapshot.contextId !== message.contextId) ||
|
|
988
|
-
|
|
998
|
+
isTerminalTask(snapshot))
|
|
989
999
|
return;
|
|
990
1000
|
const run = await this.context(snapshot.contextId, context);
|
|
991
1001
|
let task = run.tasks.get(snapshot.id);
|
|
@@ -1016,7 +1026,7 @@ export class NativeInbox {
|
|
|
1016
1026
|
/** Reattach a cold task before the SDK decides whether cancellation has a live executor. */
|
|
1017
1027
|
async beforeTask(taskId, context) {
|
|
1018
1028
|
const snapshot = await this.store.load(taskId, context);
|
|
1019
|
-
if (!snapshot ||
|
|
1029
|
+
if (!snapshot || isTerminalTask(snapshot))
|
|
1020
1030
|
return;
|
|
1021
1031
|
const run = await this.context(snapshot.contextId, context);
|
|
1022
1032
|
const task = run.tasks.get(taskId) ?? this.restoreTask(run, snapshot, context);
|
|
@@ -1034,15 +1044,6 @@ export class NativeInbox {
|
|
|
1034
1044
|
task.status?.state === TaskState.TASK_STATE_REJECTED)
|
|
1035
1045
|
return 'failed';
|
|
1036
1046
|
}
|
|
1037
|
-
terminal(task) {
|
|
1038
|
-
return (!!task.status &&
|
|
1039
|
-
[
|
|
1040
|
-
TaskState.TASK_STATE_COMPLETED,
|
|
1041
|
-
TaskState.TASK_STATE_CANCELED,
|
|
1042
|
-
TaskState.TASK_STATE_FAILED,
|
|
1043
|
-
TaskState.TASK_STATE_REJECTED,
|
|
1044
|
-
].includes(task.status.state));
|
|
1045
|
-
}
|
|
1046
1047
|
async admit(run, task, a2aMessage, context, target, wakeup, accepted) {
|
|
1047
1048
|
if (run.paused)
|
|
1048
1049
|
throw new Error('Inbox is paused.');
|
|
@@ -1060,7 +1061,7 @@ export class NativeInbox {
|
|
|
1060
1061
|
const headers = context.state.get(STATE_HEADERS_KEY);
|
|
1061
1062
|
const work = run.admissionTail.then(async () => {
|
|
1062
1063
|
abort.signal.throwIfAborted();
|
|
1063
|
-
const content = await this.content(a2aMessage, run.entry.sessionId);
|
|
1064
|
+
const content = await this.content(a2aMessage, run.entry.sessionId, abort.signal);
|
|
1064
1065
|
abort.signal.throwIfAborted();
|
|
1065
1066
|
const message = createUserMessage({
|
|
1066
1067
|
content,
|
|
@@ -1149,7 +1150,7 @@ export class NativeInbox {
|
|
|
1149
1150
|
let task;
|
|
1150
1151
|
let ownsTask = false;
|
|
1151
1152
|
try {
|
|
1152
|
-
const controls = this.controls(userMessage
|
|
1153
|
+
const controls = this.controls(userMessage);
|
|
1153
1154
|
const run = await this.context(contextId, context);
|
|
1154
1155
|
task = run.tasks.get(taskId);
|
|
1155
1156
|
if (!task) {
|
|
@@ -1212,13 +1213,23 @@ export class NativeInbox {
|
|
|
1212
1213
|
bus.finished();
|
|
1213
1214
|
}
|
|
1214
1215
|
}
|
|
1215
|
-
|
|
1216
|
+
committedInputs(run, taskIds) {
|
|
1217
|
+
const inputs = new Map();
|
|
1216
1218
|
for (const event of run.entry.handle.agent.session.snapshotEvents()) {
|
|
1217
|
-
if (event.type
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1219
|
+
if (event.type !== 'user/message' || event.data.source.kind !== 'a2a')
|
|
1220
|
+
continue;
|
|
1221
|
+
const source = event.data.source;
|
|
1222
|
+
if (!taskIds.has(source.taskId))
|
|
1223
|
+
continue;
|
|
1224
|
+
const messages = inputs.get(source.taskId) ?? [];
|
|
1225
|
+
messages.push(Message.fromJSON(JSON.parse(source.message)));
|
|
1226
|
+
inputs.set(source.taskId, messages);
|
|
1221
1227
|
}
|
|
1228
|
+
return inputs;
|
|
1229
|
+
}
|
|
1230
|
+
restoreTask(run, snapshot, context, committed = this.committedInputs(run, new Set([snapshot.id])).get(snapshot.id) ?? []) {
|
|
1231
|
+
for (const message of committed)
|
|
1232
|
+
commitInboxInput(snapshot.history, message);
|
|
1222
1233
|
if (!this.server)
|
|
1223
1234
|
throw new Error('A2A task recovery requires the running server.');
|
|
1224
1235
|
const bus = this.server.restoreTask(snapshot, context);
|
package/lib/stores/filesystem.js
CHANGED
|
@@ -3,7 +3,8 @@ import { createHash, randomUUID } from 'node:crypto';
|
|
|
3
3
|
import { mkdir, open, readdir, readFile, rename, rm } from 'node:fs/promises';
|
|
4
4
|
import { join, resolve } from 'node:path';
|
|
5
5
|
import { Task } from '@a2a-js/sdk';
|
|
6
|
-
import {
|
|
6
|
+
import { recoveryContextIds, taskScopeKey } from '../task-store.js';
|
|
7
|
+
import { LIST_PREFIX_BYTES, loadTaskPage, serializeTaskSnapshot, snapshotListShell, taskListShell, } from './listing.js';
|
|
7
8
|
export class FilesystemTaskStore {
|
|
8
9
|
root;
|
|
9
10
|
constructor(config) {
|
|
@@ -24,7 +25,7 @@ export class FilesystemTaskStore {
|
|
|
24
25
|
try {
|
|
25
26
|
const file = await open(temporary, 'wx', 0o600);
|
|
26
27
|
try {
|
|
27
|
-
await file.writeFile(
|
|
28
|
+
await file.writeFile(serializeTaskSnapshot(task, Task.toJSON(task)));
|
|
28
29
|
await file.sync();
|
|
29
30
|
}
|
|
30
31
|
finally {
|
|
@@ -59,7 +60,7 @@ export class FilesystemTaskStore {
|
|
|
59
60
|
async delete(id, context) {
|
|
60
61
|
await rm(this.path(id, context), { force: true });
|
|
61
62
|
}
|
|
62
|
-
async
|
|
63
|
+
async shells(context) {
|
|
63
64
|
let files;
|
|
64
65
|
try {
|
|
65
66
|
files = await readdir(this.directory(context));
|
|
@@ -69,18 +70,42 @@ export class FilesystemTaskStore {
|
|
|
69
70
|
throw error;
|
|
70
71
|
files = [];
|
|
71
72
|
}
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
73
|
+
const snapshots = files.filter((file) => /^[a-f0-9]{64}\.json$/.test(file));
|
|
74
|
+
const shells = [];
|
|
75
|
+
for (let start = 0; start < snapshots.length; start += 16) {
|
|
76
|
+
const batch = await Promise.all(snapshots.slice(start, start + 16).map(async (file) => {
|
|
77
|
+
const path = join(this.directory(context), file);
|
|
78
|
+
try {
|
|
79
|
+
const handle = await open(path, 'r');
|
|
80
|
+
let shell;
|
|
81
|
+
try {
|
|
82
|
+
const prefix = Buffer.alloc(LIST_PREFIX_BYTES);
|
|
83
|
+
const { bytesRead } = await handle.read(prefix, 0, prefix.length, 0);
|
|
84
|
+
shell = snapshotListShell(prefix.toString('utf8', 0, bytesRead));
|
|
85
|
+
// Read legacy or oversized headers through the same open inode.
|
|
86
|
+
if (!shell)
|
|
87
|
+
shell = taskListShell(Task.fromJSON(JSON.parse(await handle.readFile('utf8'))));
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
await handle.close();
|
|
91
|
+
}
|
|
92
|
+
return shell;
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
if (error.code !== 'ENOENT')
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}));
|
|
99
|
+
for (const shell of batch)
|
|
100
|
+
if (shell)
|
|
101
|
+
shells.push(shell);
|
|
83
102
|
}
|
|
84
|
-
return
|
|
103
|
+
return shells;
|
|
104
|
+
}
|
|
105
|
+
async recoveryCandidates(context) {
|
|
106
|
+
return recoveryContextIds(await this.shells(context));
|
|
107
|
+
}
|
|
108
|
+
async list(params, context) {
|
|
109
|
+
return loadTaskPage(await this.shells(context), params, (ids) => Promise.all(ids.map((id) => this.load(id, context))));
|
|
85
110
|
}
|
|
86
111
|
}
|
package/lib/stores/gcs.js
CHANGED
|
@@ -17,8 +17,11 @@ import { tmpdir } from 'node:os';
|
|
|
17
17
|
import { join } from 'node:path';
|
|
18
18
|
import { pipeline } from 'node:stream/promises';
|
|
19
19
|
import { promisify } from 'node:util';
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
20
|
+
import { gunzip, gzip } from 'node:zlib';
|
|
21
|
+
import { recoveryContextIds, taskScopeKey } from '../task-store.js';
|
|
22
|
+
import { loadTaskPage, parseListShell, taskListShell } from './listing.js';
|
|
23
|
+
const compress = promisify(gzip);
|
|
24
|
+
const decompress = promisify(gunzip);
|
|
22
25
|
export class GcsTaskStore {
|
|
23
26
|
config;
|
|
24
27
|
bucket = null;
|
|
@@ -35,19 +38,29 @@ export class GcsTaskStore {
|
|
|
35
38
|
async save(task, context) {
|
|
36
39
|
if (!this.bucket)
|
|
37
40
|
return;
|
|
41
|
+
const shell = JSON.stringify(taskListShell(task));
|
|
38
42
|
await this.bucket
|
|
39
43
|
.file(`${this.prefix}/${taskScopeKey(context)}/${task.id}/metadata.json.gz`)
|
|
40
|
-
.save(
|
|
44
|
+
.save(await compress(JSON.stringify(task)), {
|
|
45
|
+
contentType: 'application/gzip',
|
|
46
|
+
resumable: false,
|
|
47
|
+
// GCS custom metadata is bounded; oversized identifiers use the legacy read path.
|
|
48
|
+
metadata: { metadata: Buffer.byteLength(shell) <= 4096 ? { dshTaskList: shell } : {} },
|
|
49
|
+
});
|
|
41
50
|
}
|
|
42
51
|
async load(taskId, context) {
|
|
43
52
|
if (!this.bucket)
|
|
44
53
|
return undefined;
|
|
45
54
|
const file = this.bucket.file(`${this.prefix}/${taskScopeKey(context)}/${taskId}/metadata.json.gz`);
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return
|
|
49
|
-
|
|
50
|
-
|
|
55
|
+
try {
|
|
56
|
+
const [buf] = await file.download();
|
|
57
|
+
return JSON.parse((await decompress(buf)).toString('utf8'));
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (error.code === 404)
|
|
61
|
+
return undefined;
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
51
64
|
}
|
|
52
65
|
async delete(taskId, context) {
|
|
53
66
|
if (!this.bucket)
|
|
@@ -61,21 +74,54 @@ export class GcsTaskStore {
|
|
|
61
74
|
.delete({ ignoreNotFound: true }),
|
|
62
75
|
]);
|
|
63
76
|
}
|
|
64
|
-
async
|
|
77
|
+
async shells(context) {
|
|
65
78
|
if (!this.bucket)
|
|
66
|
-
return
|
|
79
|
+
return [];
|
|
67
80
|
const [files] = await this.bucket.getFiles({
|
|
68
81
|
prefix: `${this.prefix}/${taskScopeKey(context)}/`,
|
|
69
82
|
});
|
|
70
83
|
const shells = [];
|
|
71
84
|
const metadata = files.filter((file) => file.name.endsWith('/metadata.json.gz'));
|
|
72
|
-
// Bound in-flight downloads while avoiding one network round-trip per task.
|
|
73
85
|
for (let start = 0; start < metadata.length; start += 16) {
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
86
|
+
const batch = await Promise.all(metadata.slice(start, start + 16).map(async (file) => {
|
|
87
|
+
const indexed = parseListShell(file.metadata?.metadata?.dshTaskList);
|
|
88
|
+
if (indexed)
|
|
89
|
+
return indexed;
|
|
90
|
+
// Older snapshots have no summary; keep them visible without migration.
|
|
91
|
+
try {
|
|
92
|
+
const [buf] = await file.download();
|
|
93
|
+
return taskListShell(JSON.parse((await decompress(buf)).toString('utf8')));
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (error.code === 404)
|
|
97
|
+
return;
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
}));
|
|
101
|
+
for (const shell of batch)
|
|
102
|
+
if (shell)
|
|
103
|
+
shells.push(shell);
|
|
77
104
|
}
|
|
78
|
-
return
|
|
105
|
+
return shells;
|
|
106
|
+
}
|
|
107
|
+
async recoveryCandidates(context) {
|
|
108
|
+
if (!this.bucket)
|
|
109
|
+
throw new Error('[dsh-a2a] GCS TaskStore is not initialized.');
|
|
110
|
+
return recoveryContextIds(await this.shells(context));
|
|
111
|
+
}
|
|
112
|
+
async list(params, context) {
|
|
113
|
+
if (!this.bucket)
|
|
114
|
+
return { tasks: [], nextPageToken: '', pageSize: 0, totalSize: 0 };
|
|
115
|
+
return loadTaskPage(await this.shells(context), params, (ids) => Promise.all(ids.map(async (id) => {
|
|
116
|
+
try {
|
|
117
|
+
return await this.load(id, context);
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
if (error.code === 404)
|
|
121
|
+
return;
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
})));
|
|
79
125
|
}
|
|
80
126
|
/**
|
|
81
127
|
* Tar + gzip the workspace directory to `tasks/{scope}/{taskId}/workspace.tar.gz`.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { listShells } from '../task-store.js';
|
|
2
|
+
export const LIST_PREFIX_BYTES = 8192;
|
|
3
|
+
const HEADER = '{"_dshTaskList":';
|
|
4
|
+
/** Only the fields needed by listShells; never retain history or status-message content. */
|
|
5
|
+
export function taskListShell(task) {
|
|
6
|
+
return {
|
|
7
|
+
id: task.id,
|
|
8
|
+
contextId: task.contextId,
|
|
9
|
+
status: task.status && {
|
|
10
|
+
state: task.status.state,
|
|
11
|
+
message: undefined,
|
|
12
|
+
timestamp: task.status.timestamp,
|
|
13
|
+
},
|
|
14
|
+
history: [],
|
|
15
|
+
artifacts: [],
|
|
16
|
+
metadata: undefined,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function parseListShell(value) {
|
|
20
|
+
if (typeof value !== 'string')
|
|
21
|
+
return;
|
|
22
|
+
try {
|
|
23
|
+
const shell = JSON.parse(value);
|
|
24
|
+
if (!shell ||
|
|
25
|
+
typeof shell.id !== 'string' ||
|
|
26
|
+
typeof shell.contextId !== 'string' ||
|
|
27
|
+
(shell.status !== undefined &&
|
|
28
|
+
(!shell.status ||
|
|
29
|
+
!Number.isInteger(shell.status.state) ||
|
|
30
|
+
(shell.status.timestamp !== undefined && typeof shell.status.timestamp !== 'string'))))
|
|
31
|
+
return;
|
|
32
|
+
return taskListShell(shell);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** A JSON-compatible header shares the snapshot's atomic write, including on older readers. */
|
|
39
|
+
export function serializeTaskSnapshot(task, json = task) {
|
|
40
|
+
return `${HEADER}${JSON.stringify(taskListShell(task))},\n${JSON.stringify(json).slice(1)}`;
|
|
41
|
+
}
|
|
42
|
+
export function snapshotListShell(prefix) {
|
|
43
|
+
if (!prefix.startsWith(HEADER))
|
|
44
|
+
return;
|
|
45
|
+
const newline = prefix.indexOf('\n');
|
|
46
|
+
if (newline < 0 || prefix[newline - 1] !== ',')
|
|
47
|
+
return;
|
|
48
|
+
return parseListShell(prefix.slice(HEADER.length, newline - 1));
|
|
49
|
+
}
|
|
50
|
+
/** Hydrate only the selected page, with bounded I/O and the existing artifact semantics. */
|
|
51
|
+
export async function loadTaskPage(shells, params, load) {
|
|
52
|
+
const page = listShells(shells, params);
|
|
53
|
+
const tasks = [];
|
|
54
|
+
for (let start = 0; start < page.tasks.length; start += 16) {
|
|
55
|
+
const batch = await load(page.tasks.slice(start, start + 16).map((task) => task.id));
|
|
56
|
+
for (const task of batch) {
|
|
57
|
+
// A task can expire or be deleted between enumeration and hydration.
|
|
58
|
+
if (task)
|
|
59
|
+
tasks.push(params.includeArtifacts ? task : { ...task, artifacts: [] });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { ...page, tasks };
|
|
63
|
+
}
|
package/lib/stores/redis.js
CHANGED
|
@@ -6,7 +6,12 @@
|
|
|
6
6
|
* snapshots arrive from SanitizedTaskStore at state transitions;
|
|
7
7
|
* dsh-storage separately owns the full conversation log (ai_messages).
|
|
8
8
|
*/
|
|
9
|
-
import {
|
|
9
|
+
import { recoveryContextIds, taskScopeKey } from '../task-store.js';
|
|
10
|
+
import { LIST_PREFIX_BYTES, loadTaskPage, serializeTaskSnapshot, snapshotListShell, taskListShell, } from './listing.js';
|
|
11
|
+
function parseSnapshot(raw) {
|
|
12
|
+
const { _dshTaskList: _, ...task } = JSON.parse(raw);
|
|
13
|
+
return task;
|
|
14
|
+
}
|
|
10
15
|
export class RedisTaskStore {
|
|
11
16
|
config;
|
|
12
17
|
redis = null;
|
|
@@ -27,34 +32,72 @@ export class RedisTaskStore {
|
|
|
27
32
|
async save(task, context) {
|
|
28
33
|
if (!this.redis)
|
|
29
34
|
return;
|
|
30
|
-
await this.redis.set(this.taskKey(task.id, context),
|
|
35
|
+
await this.redis.set(this.taskKey(task.id, context), serializeTaskSnapshot(task), 'EX', this.ttl);
|
|
31
36
|
}
|
|
32
37
|
async load(taskId, context) {
|
|
33
38
|
if (!this.redis)
|
|
34
39
|
return undefined;
|
|
35
40
|
const raw = await this.redis.get(this.taskKey(taskId, context));
|
|
36
|
-
return raw ?
|
|
41
|
+
return raw ? parseSnapshot(raw) : undefined;
|
|
37
42
|
}
|
|
38
43
|
async delete(taskId, context) {
|
|
39
44
|
await this.redis?.del(this.taskKey(taskId, context));
|
|
40
45
|
}
|
|
41
|
-
async
|
|
46
|
+
async shells(context) {
|
|
42
47
|
if (!this.redis)
|
|
43
|
-
return
|
|
44
|
-
//
|
|
48
|
+
return [];
|
|
49
|
+
// SCAN only names; bounded prefixes supply the lightweight list summaries.
|
|
45
50
|
const shells = [];
|
|
51
|
+
const seen = new Set();
|
|
46
52
|
let cursor = '0';
|
|
47
53
|
do {
|
|
48
|
-
const [next,
|
|
54
|
+
const [next, scanned] = await this.redis.scan(cursor, 'MATCH', `${this.prefix}:tasks:${taskScopeKey(context)}:*`, 'COUNT', 100);
|
|
49
55
|
cursor = next;
|
|
56
|
+
// SCAN may repeat keys during rehashing, including within one batch.
|
|
57
|
+
const keys = scanned.filter((key) => {
|
|
58
|
+
if (seen.has(key))
|
|
59
|
+
return false;
|
|
60
|
+
seen.add(key);
|
|
61
|
+
return true;
|
|
62
|
+
});
|
|
50
63
|
if (keys.length) {
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
64
|
+
const prefixes = await this.redis
|
|
65
|
+
.pipeline(keys.map((key) => ['getrange', key, 0, LIST_PREFIX_BYTES - 1]))
|
|
66
|
+
.exec();
|
|
67
|
+
const legacy = [];
|
|
68
|
+
prefixes.forEach(([error, prefix], index) => {
|
|
69
|
+
if (error)
|
|
70
|
+
throw error;
|
|
71
|
+
if (!prefix)
|
|
72
|
+
return; // Expired since SCAN.
|
|
73
|
+
const shell = snapshotListShell(prefix);
|
|
74
|
+
if (shell)
|
|
75
|
+
shells.push(shell);
|
|
76
|
+
else
|
|
77
|
+
legacy.push(keys[index]);
|
|
78
|
+
});
|
|
79
|
+
if (legacy.length) {
|
|
80
|
+
const raws = await this.redis.mget(...legacy);
|
|
81
|
+
for (const raw of raws)
|
|
82
|
+
if (raw)
|
|
83
|
+
shells.push(taskListShell(parseSnapshot(raw)));
|
|
84
|
+
}
|
|
55
85
|
}
|
|
56
86
|
} while (cursor !== '0');
|
|
57
|
-
return
|
|
87
|
+
return shells;
|
|
88
|
+
}
|
|
89
|
+
async recoveryCandidates(context) {
|
|
90
|
+
if (!this.redis)
|
|
91
|
+
throw new Error('[dsh-a2a] Redis TaskStore is not initialized.');
|
|
92
|
+
return recoveryContextIds(await this.shells(context));
|
|
93
|
+
}
|
|
94
|
+
async list(params, context) {
|
|
95
|
+
if (!this.redis)
|
|
96
|
+
return { tasks: [], nextPageToken: '', pageSize: 0, totalSize: 0 };
|
|
97
|
+
return loadTaskPage(await this.shells(context), params, async (ids) => {
|
|
98
|
+
const raws = await this.redis.mget(...ids.map((id) => this.taskKey(id, context)));
|
|
99
|
+
return raws.map((raw) => (raw ? parseSnapshot(raw) : undefined));
|
|
100
|
+
});
|
|
58
101
|
}
|
|
59
102
|
async close() {
|
|
60
103
|
await this.redis?.quit?.();
|
package/lib/task-store.js
CHANGED
|
@@ -52,6 +52,40 @@ export function sanitizeTask(task) {
|
|
|
52
52
|
? { ...task, history: task.history.filter((message) => message.messageId !== current) }
|
|
53
53
|
: task;
|
|
54
54
|
}
|
|
55
|
+
export function isTerminalTask(task) {
|
|
56
|
+
return [
|
|
57
|
+
TaskState.TASK_STATE_COMPLETED,
|
|
58
|
+
TaskState.TASK_STATE_CANCELED,
|
|
59
|
+
TaskState.TASK_STATE_FAILED,
|
|
60
|
+
TaskState.TASK_STATE_REJECTED,
|
|
61
|
+
].includes(task.status?.state ?? TaskState.TASK_STATE_UNSPECIFIED);
|
|
62
|
+
}
|
|
63
|
+
export function recoveryContextIds(tasks) {
|
|
64
|
+
return [
|
|
65
|
+
...new Set(tasks.filter((task) => !isTerminalTask(task)).map((task) => task.contextId)),
|
|
66
|
+
].sort();
|
|
67
|
+
}
|
|
68
|
+
/** Custom stores retain a complete, if more expensive, discovery path. */
|
|
69
|
+
export async function findRecoveryCandidates(store, context) {
|
|
70
|
+
if (store.recoveryCandidates)
|
|
71
|
+
return store.recoveryCandidates(context);
|
|
72
|
+
const ids = new Set();
|
|
73
|
+
let pageToken = '';
|
|
74
|
+
do {
|
|
75
|
+
const page = await store.list({
|
|
76
|
+
tenant: '',
|
|
77
|
+
contextId: '',
|
|
78
|
+
pageSize: 100,
|
|
79
|
+
pageToken,
|
|
80
|
+
status: TaskState.TASK_STATE_UNSPECIFIED,
|
|
81
|
+
statusTimestampAfter: undefined,
|
|
82
|
+
}, context);
|
|
83
|
+
for (const id of recoveryContextIds(page.tasks))
|
|
84
|
+
ids.add(id);
|
|
85
|
+
pageToken = page.nextPageToken;
|
|
86
|
+
} while (pageToken);
|
|
87
|
+
return [...ids].sort();
|
|
88
|
+
}
|
|
55
89
|
function scopeOf(context) {
|
|
56
90
|
return JSON.stringify([context?.tenant ?? '', context ? resolveUserScope(context) : 'unknown']);
|
|
57
91
|
}
|
|
@@ -81,6 +115,9 @@ export class MemoryTaskStore {
|
|
|
81
115
|
async list(params, context) {
|
|
82
116
|
return listShells([...this.bucket(context).values()], params);
|
|
83
117
|
}
|
|
118
|
+
async recoveryCandidates(context) {
|
|
119
|
+
return recoveryContextIds([...this.bucket(context).values()]);
|
|
120
|
+
}
|
|
84
121
|
async delete(taskId, context) {
|
|
85
122
|
this.bucket(context).delete(taskId);
|
|
86
123
|
}
|
|
@@ -137,13 +174,7 @@ export class SanitizedTaskStore {
|
|
|
137
174
|
this.terminalStates.delete(task.id);
|
|
138
175
|
else if (terminal && state !== terminal.state)
|
|
139
176
|
return;
|
|
140
|
-
if (state !== undefined &&
|
|
141
|
-
[
|
|
142
|
-
TaskState.TASK_STATE_COMPLETED,
|
|
143
|
-
TaskState.TASK_STATE_CANCELED,
|
|
144
|
-
TaskState.TASK_STATE_FAILED,
|
|
145
|
-
TaskState.TASK_STATE_REJECTED,
|
|
146
|
-
].includes(state)) {
|
|
177
|
+
if (state !== undefined && isTerminalTask(task)) {
|
|
147
178
|
// Map order matches expiry order: only the expired prefix needs scanning.
|
|
148
179
|
for (const [id, guard] of this.terminalStates) {
|
|
149
180
|
if (guard.expiresAt > now)
|
|
@@ -237,6 +268,9 @@ export class SanitizedTaskStore {
|
|
|
237
268
|
list(params, context) {
|
|
238
269
|
return this.inner.list(params, context);
|
|
239
270
|
}
|
|
271
|
+
recoveryCandidates(context) {
|
|
272
|
+
return findRecoveryCandidates(this.inner, context);
|
|
273
|
+
}
|
|
240
274
|
}
|
|
241
275
|
/**
|
|
242
276
|
* Shared `listTasks` semantics for the backends: filter by contextId / status /
|