@amaster.ai/dsh-a2a 0.1.38 → 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 +41 -4
- package/lib/bridge.js +32 -9
- package/lib/content.js +139 -96
- package/lib/executor.js +4 -3
- package/lib/inbox.js +48 -34
- 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 {
|
|
@@ -982,7 +995,7 @@ export class NativeInbox {
|
|
|
982
995
|
const snapshot = await this.store.load(message.taskId, context);
|
|
983
996
|
if (!snapshot ||
|
|
984
997
|
(message.contextId && snapshot.contextId !== message.contextId) ||
|
|
985
|
-
|
|
998
|
+
isTerminalTask(snapshot))
|
|
986
999
|
return;
|
|
987
1000
|
const run = await this.context(snapshot.contextId, context);
|
|
988
1001
|
let task = run.tasks.get(snapshot.id);
|
|
@@ -1013,7 +1026,7 @@ export class NativeInbox {
|
|
|
1013
1026
|
/** Reattach a cold task before the SDK decides whether cancellation has a live executor. */
|
|
1014
1027
|
async beforeTask(taskId, context) {
|
|
1015
1028
|
const snapshot = await this.store.load(taskId, context);
|
|
1016
|
-
if (!snapshot ||
|
|
1029
|
+
if (!snapshot || isTerminalTask(snapshot))
|
|
1017
1030
|
return;
|
|
1018
1031
|
const run = await this.context(snapshot.contextId, context);
|
|
1019
1032
|
const task = run.tasks.get(taskId) ?? this.restoreTask(run, snapshot, context);
|
|
@@ -1031,15 +1044,6 @@ export class NativeInbox {
|
|
|
1031
1044
|
task.status?.state === TaskState.TASK_STATE_REJECTED)
|
|
1032
1045
|
return 'failed';
|
|
1033
1046
|
}
|
|
1034
|
-
terminal(task) {
|
|
1035
|
-
return (!!task.status &&
|
|
1036
|
-
[
|
|
1037
|
-
TaskState.TASK_STATE_COMPLETED,
|
|
1038
|
-
TaskState.TASK_STATE_CANCELED,
|
|
1039
|
-
TaskState.TASK_STATE_FAILED,
|
|
1040
|
-
TaskState.TASK_STATE_REJECTED,
|
|
1041
|
-
].includes(task.status.state));
|
|
1042
|
-
}
|
|
1043
1047
|
async admit(run, task, a2aMessage, context, target, wakeup, accepted) {
|
|
1044
1048
|
if (run.paused)
|
|
1045
1049
|
throw new Error('Inbox is paused.');
|
|
@@ -1057,7 +1061,7 @@ export class NativeInbox {
|
|
|
1057
1061
|
const headers = context.state.get(STATE_HEADERS_KEY);
|
|
1058
1062
|
const work = run.admissionTail.then(async () => {
|
|
1059
1063
|
abort.signal.throwIfAborted();
|
|
1060
|
-
const content = await this.content(a2aMessage, run.entry.sessionId);
|
|
1064
|
+
const content = await this.content(a2aMessage, run.entry.sessionId, abort.signal);
|
|
1061
1065
|
abort.signal.throwIfAborted();
|
|
1062
1066
|
const message = createUserMessage({
|
|
1063
1067
|
content,
|
|
@@ -1209,13 +1213,23 @@ export class NativeInbox {
|
|
|
1209
1213
|
bus.finished();
|
|
1210
1214
|
}
|
|
1211
1215
|
}
|
|
1212
|
-
|
|
1216
|
+
committedInputs(run, taskIds) {
|
|
1217
|
+
const inputs = new Map();
|
|
1213
1218
|
for (const event of run.entry.handle.agent.session.snapshotEvents()) {
|
|
1214
|
-
if (event.type
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
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);
|
|
1218
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);
|
|
1219
1233
|
if (!this.server)
|
|
1220
1234
|
throw new Error('A2A task recovery requires the running server.');
|
|
1221
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 /
|
package/lib/types/bridge.d.ts
CHANGED
|
@@ -79,6 +79,7 @@ export declare class A2aBridge {
|
|
|
79
79
|
private readonly executing;
|
|
80
80
|
private readonly asked;
|
|
81
81
|
private readonly approvals;
|
|
82
|
+
private readonly preparing;
|
|
82
83
|
private readonly codec;
|
|
83
84
|
private readonly delegation;
|
|
84
85
|
private readonly children;
|
|
@@ -96,8 +97,8 @@ export declare class A2aBridge {
|
|
|
96
97
|
}>;
|
|
97
98
|
private ensureTaskInner;
|
|
98
99
|
private createAgent;
|
|
99
|
-
/** A2A parts → dsh content blocks;
|
|
100
|
-
buildContent(message: Message, contextId: string): Promise<ContentBlock[]>;
|
|
100
|
+
/** A2A parts → dsh content blocks; rejects unusable messages and cancellation. */
|
|
101
|
+
buildContent(message: Message, contextId: string, signal?: AbortSignal, taskId?: string): Promise<ContentBlock[]>;
|
|
101
102
|
isClearing(contextId: string): boolean;
|
|
102
103
|
/** Called before the SDK starts an executor, so a bad reply cannot fail the live task. */
|
|
103
104
|
validateApprovalMessage(message: Message): boolean;
|
package/lib/types/content.d.ts
CHANGED
|
@@ -26,6 +26,7 @@ export interface A2aFileMaterializer {
|
|
|
26
26
|
bytes: Uint8Array;
|
|
27
27
|
filename: string;
|
|
28
28
|
mediaType: string;
|
|
29
|
+
signal?: AbortSignal;
|
|
29
30
|
}): Promise<{
|
|
30
31
|
readablePath: string;
|
|
31
32
|
}>;
|
|
@@ -40,7 +41,7 @@ export interface UrlDownloadOptions {
|
|
|
40
41
|
/** Private/loopback hosts are refused unless explicitly named here. */
|
|
41
42
|
allowedHosts?: readonly string[];
|
|
42
43
|
}
|
|
43
|
-
export declare function buildMessageContent(parts: readonly Part[], attachments: AttachmentStore | undefined, dir: string, materialization?: Materialization, downloads?: UrlDownloadOptions): Promise<ContentBlock[]>;
|
|
44
|
+
export declare function buildMessageContent(parts: readonly Part[], attachments: AttachmentStore | undefined, dir: string, materialization?: Materialization, downloads?: UrlDownloadOptions, signal?: AbortSignal): Promise<ContentBlock[]>;
|
|
44
45
|
/** The directory non-image file parts persist into without a materializer. */
|
|
45
46
|
export declare function uploadsDir(root?: string): string;
|
|
46
47
|
export {};
|