loop-task 1.3.0 → 1.4.1
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 +279 -195
- package/dist/board/App.js +225 -87
- package/dist/board/components/ActionButtons.js +28 -8
- package/dist/board/components/CreateForm.js +230 -70
- package/dist/board/components/CreateProjectModal.js +104 -0
- package/dist/board/components/DeleteProjectConfirm.js +53 -0
- package/dist/board/components/EditProjectModal.js +106 -0
- package/dist/board/components/FilterBar.js +5 -4
- package/dist/board/components/Footer.js +19 -6
- package/dist/board/components/Header.js +33 -3
- package/dist/board/components/HelpModal.js +11 -10
- package/dist/board/components/Inspector.js +1 -1
- package/dist/board/components/LogModal.js +39 -8
- package/dist/board/components/Navigator.js +30 -14
- package/dist/board/components/ProjectsModal.js +70 -0
- package/dist/board/components/ProjectsPage.js +105 -0
- package/dist/board/components/RunHistory.js +48 -9
- package/dist/board/components/TaskBrowser.js +96 -0
- package/dist/board/components/TaskFilterBar.js +6 -0
- package/dist/board/components/TaskForm.js +175 -0
- package/dist/board/daemon.js +56 -0
- package/dist/board/format.js +19 -12
- package/dist/board/hooks/useBoardKeybindings.js +264 -156
- package/dist/board/hooks/useTaskKeybindings.js +130 -0
- package/dist/board/router.js +16 -0
- package/dist/board/state.js +18 -14
- package/dist/cli.js +61 -6
- package/dist/client/commands.js +157 -1
- package/dist/config/constants.js +15 -0
- package/dist/config/paths.js +6 -0
- package/dist/core/command-runner.js +2 -0
- package/dist/core/foreground-loop.js +4 -1
- package/dist/core/loop-controller.js +210 -32
- package/dist/daemon/index.js +5 -2
- package/dist/daemon/manager.js +113 -34
- package/dist/daemon/projects.js +104 -0
- package/dist/daemon/server.js +177 -6
- package/dist/daemon/state.js +32 -1
- package/dist/daemon/task-manager.js +58 -0
- package/dist/i18n/en.json +140 -21
- package/dist/loop-config.js +13 -4
- package/dist/shared/clipboard.js +19 -0
- package/package.json +13 -3
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { writeFileAtomic } from "../shared/fs-utils.js";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { daemonLog } from "./daemon-log.js";
|
|
6
|
+
import crypto from "node:crypto";
|
|
7
|
+
export class ProjectManager {
|
|
8
|
+
projects = new Map();
|
|
9
|
+
projectsDir;
|
|
10
|
+
constructor(loopCliHome) {
|
|
11
|
+
const baseDir = loopCliHome || join(homedir(), ".loop-cli");
|
|
12
|
+
this.projectsDir = join(baseDir, "projects");
|
|
13
|
+
}
|
|
14
|
+
init() {
|
|
15
|
+
if (!fs.existsSync(this.projectsDir)) {
|
|
16
|
+
fs.mkdirSync(this.projectsDir, { recursive: true });
|
|
17
|
+
}
|
|
18
|
+
this.loadProjects();
|
|
19
|
+
if (!this.projects.has("default")) {
|
|
20
|
+
this.createDefaultProject();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
loadProjects() {
|
|
24
|
+
this.projects.clear();
|
|
25
|
+
if (!fs.existsSync(this.projectsDir))
|
|
26
|
+
return;
|
|
27
|
+
try {
|
|
28
|
+
const files = fs.readdirSync(this.projectsDir);
|
|
29
|
+
for (const file of files) {
|
|
30
|
+
if (!file.endsWith(".json"))
|
|
31
|
+
continue;
|
|
32
|
+
try {
|
|
33
|
+
const raw = fs.readFileSync(join(this.projectsDir, file), "utf-8");
|
|
34
|
+
const content = JSON.parse(raw);
|
|
35
|
+
if (content.id)
|
|
36
|
+
this.projects.set(content.id, content);
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
daemonLog(`Failed to load project ${file}: ${err}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
daemonLog(`Failed to load projects directory: ${err}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
saveProject(project) {
|
|
48
|
+
const filePath = join(this.projectsDir, `${project.id}.json`);
|
|
49
|
+
writeFileAtomic(filePath, JSON.stringify(project, null, 2));
|
|
50
|
+
this.projects.set(project.id, project);
|
|
51
|
+
}
|
|
52
|
+
createDefaultProject() {
|
|
53
|
+
const defaultProject = {
|
|
54
|
+
id: "default",
|
|
55
|
+
name: "Default",
|
|
56
|
+
color: "#ffffff",
|
|
57
|
+
createdAt: new Date().toISOString(),
|
|
58
|
+
isSystem: true,
|
|
59
|
+
isDefault: true,
|
|
60
|
+
};
|
|
61
|
+
this.saveProject(defaultProject);
|
|
62
|
+
}
|
|
63
|
+
getAll() {
|
|
64
|
+
return Array.from(this.projects.values());
|
|
65
|
+
}
|
|
66
|
+
get(id) {
|
|
67
|
+
return this.projects.get(id);
|
|
68
|
+
}
|
|
69
|
+
create(name, color) {
|
|
70
|
+
const id = crypto.randomBytes(4).toString("hex");
|
|
71
|
+
const project = {
|
|
72
|
+
id,
|
|
73
|
+
name,
|
|
74
|
+
color,
|
|
75
|
+
createdAt: new Date().toISOString(),
|
|
76
|
+
isSystem: false,
|
|
77
|
+
isDefault: false,
|
|
78
|
+
};
|
|
79
|
+
this.saveProject(project);
|
|
80
|
+
return project;
|
|
81
|
+
}
|
|
82
|
+
update(id, name, color) {
|
|
83
|
+
const project = this.projects.get(id);
|
|
84
|
+
if (!project)
|
|
85
|
+
throw new Error(`Project ${id} not found`);
|
|
86
|
+
if (project.isSystem)
|
|
87
|
+
throw new Error("Cannot rename system project");
|
|
88
|
+
project.name = name;
|
|
89
|
+
if (color)
|
|
90
|
+
project.color = color;
|
|
91
|
+
this.saveProject(project);
|
|
92
|
+
}
|
|
93
|
+
delete(id) {
|
|
94
|
+
const project = this.projects.get(id);
|
|
95
|
+
if (!project)
|
|
96
|
+
throw new Error(`Project ${id} not found`);
|
|
97
|
+
if (project.isSystem)
|
|
98
|
+
throw new Error("Cannot delete system project");
|
|
99
|
+
const filePath = join(this.projectsDir, `${id}.json`);
|
|
100
|
+
if (fs.existsSync(filePath))
|
|
101
|
+
fs.rmSync(filePath);
|
|
102
|
+
this.projects.delete(id);
|
|
103
|
+
}
|
|
104
|
+
}
|
package/dist/daemon/server.js
CHANGED
|
@@ -6,14 +6,15 @@ import { LOG_TAIL_DEFAULT } from "../config/constants.js";
|
|
|
6
6
|
import { send } from "../ipc/send.js";
|
|
7
7
|
import { tail } from "../shared/tail.js";
|
|
8
8
|
import { streamLogFollow } from "../ipc/handlers/logs-stream.js";
|
|
9
|
-
import { splitLogByRuns } from "../core/log-parser.js";
|
|
10
9
|
export class IpcServer {
|
|
11
10
|
server;
|
|
12
11
|
manager;
|
|
12
|
+
taskManager;
|
|
13
13
|
socketPath;
|
|
14
14
|
clients = new Set();
|
|
15
|
-
constructor(manager) {
|
|
15
|
+
constructor(manager, taskManager) {
|
|
16
16
|
this.manager = manager;
|
|
17
|
+
this.taskManager = taskManager;
|
|
17
18
|
this.socketPath = getSocketPath();
|
|
18
19
|
this.server = net.createServer((socket) => this.handleConnection(socket));
|
|
19
20
|
}
|
|
@@ -107,7 +108,27 @@ export class IpcServer {
|
|
|
107
108
|
this.respondOk(socket, this.manager.resume(request.payload.id), request.payload.id);
|
|
108
109
|
break;
|
|
109
110
|
}
|
|
111
|
+
case "stop-loop": {
|
|
112
|
+
this.respondOk(socket, this.manager.stopLoop(request.payload.id), request.payload.id);
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
case "play-loop": {
|
|
116
|
+
if (this.manager.isMaxRunsBlocked(request.payload.id)) {
|
|
117
|
+
send(socket, { type: "error", message: t("errors.maxRunsReached") });
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
this.respondOk(socket, this.manager.playLoop(request.payload.id), request.payload.id);
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
110
123
|
case "trigger": {
|
|
124
|
+
if (this.manager.isMaxRunsBlocked(request.payload.id)) {
|
|
125
|
+
send(socket, { type: "error", message: t("errors.maxRunsReached") });
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
if (this.manager.isRunning(request.payload.id)) {
|
|
129
|
+
send(socket, { type: "error", message: t("errors.triggerWhileRunning") });
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
111
132
|
this.respondOk(socket, this.manager.trigger(request.payload.id), request.payload.id);
|
|
112
133
|
break;
|
|
113
134
|
}
|
|
@@ -120,11 +141,78 @@ export class IpcServer {
|
|
|
120
141
|
this.handleRunLog(request, socket);
|
|
121
142
|
break;
|
|
122
143
|
}
|
|
144
|
+
case "run-log-stream": {
|
|
145
|
+
this.handleRunLogStream(request, socket);
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
123
148
|
case "attach":
|
|
124
149
|
case "logs": {
|
|
125
150
|
this.handleLogs(request, socket);
|
|
126
151
|
break;
|
|
127
152
|
}
|
|
153
|
+
case "task-create": {
|
|
154
|
+
const task = this.taskManager.create(request.payload);
|
|
155
|
+
send(socket, { type: "ok", data: task });
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
case "task-update": {
|
|
159
|
+
const updated = this.taskManager.update(request.payload.id, request.payload);
|
|
160
|
+
this.respondOk(socket, updated !== null, request.payload.id, updated ?? undefined);
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
case "task-list": {
|
|
164
|
+
send(socket, { type: "ok", data: this.taskManager.list() });
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
case "task-get": {
|
|
168
|
+
const task = this.taskManager.get(request.payload.id);
|
|
169
|
+
this.respondOk(socket, task !== null, request.payload.id, task ?? undefined);
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
case "task-delete": {
|
|
173
|
+
const ok = this.taskManager.delete(request.payload.id);
|
|
174
|
+
this.respondOk(socket, ok, request.payload.id);
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
case "project-list": {
|
|
178
|
+
send(socket, { type: "ok", data: this.manager.listProjects() });
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
case "project-create": {
|
|
182
|
+
const { name, color } = request.payload;
|
|
183
|
+
if (!name || !name.trim()) {
|
|
184
|
+
send(socket, { type: "error", message: t("project.error.nameRequired") });
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
const project = this.manager.createProject(name.trim(), color);
|
|
188
|
+
send(socket, { type: "ok", data: project });
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
case "project-update": {
|
|
192
|
+
const { id, name, color } = request.payload;
|
|
193
|
+
if (!name || !name.trim()) {
|
|
194
|
+
send(socket, { type: "error", message: t("project.error.nameEmpty") });
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
this.manager.updateProject(id, name.trim(), color);
|
|
199
|
+
send(socket, { type: "ok" });
|
|
200
|
+
}
|
|
201
|
+
catch (err) {
|
|
202
|
+
send(socket, { type: "error", message: err instanceof Error ? err.message : String(err) });
|
|
203
|
+
}
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
case "project-delete": {
|
|
207
|
+
try {
|
|
208
|
+
this.manager.deleteProject(request.payload.id);
|
|
209
|
+
send(socket, { type: "ok" });
|
|
210
|
+
}
|
|
211
|
+
catch (err) {
|
|
212
|
+
send(socket, { type: "error", message: err instanceof Error ? err.message : String(err) });
|
|
213
|
+
}
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
128
216
|
case "shutdown": {
|
|
129
217
|
send(socket, { type: "ok" });
|
|
130
218
|
await this.manager.shutdown();
|
|
@@ -140,10 +228,93 @@ export class IpcServer {
|
|
|
140
228
|
send(socket, { type: "ok", data: "" });
|
|
141
229
|
return;
|
|
142
230
|
}
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
231
|
+
const meta = this.manager.status(id);
|
|
232
|
+
if (!meta) {
|
|
233
|
+
send(socket, { type: "ok", data: "" });
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const records = meta.runHistory
|
|
237
|
+
.filter((r) => r.runNumber === runNumber)
|
|
238
|
+
.sort((a, b) => a.logOffset - b.logOffset);
|
|
239
|
+
if (records.length === 0) {
|
|
240
|
+
send(socket, { type: "ok", data: "" });
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const buffer = fs.readFileSync(logPath);
|
|
244
|
+
const allSorted = meta.runHistory.slice().sort((a, b) => a.logOffset - b.logOffset);
|
|
245
|
+
const parts = records.map((record) => {
|
|
246
|
+
const start = record.logOffset;
|
|
247
|
+
const idx = allSorted.indexOf(record);
|
|
248
|
+
const end = idx < allSorted.length - 1 ? allSorted[idx + 1].logOffset : buffer.length;
|
|
249
|
+
return buffer.toString("utf-8", start, end);
|
|
250
|
+
});
|
|
251
|
+
send(socket, { type: "ok", data: parts.join("") });
|
|
252
|
+
}
|
|
253
|
+
handleRunLogStream(request, socket) {
|
|
254
|
+
const { id, runNumber } = request.payload;
|
|
255
|
+
const meta = this.manager.status(id);
|
|
256
|
+
if (!meta) {
|
|
257
|
+
send(socket, { type: "error", message: t("errors.loopNotFound", { id }) });
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const record = meta.runHistory.find((r) => r.runNumber === runNumber);
|
|
261
|
+
if (!record) {
|
|
262
|
+
send(socket, { type: "ok", data: "" });
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const logPath = this.manager.getLogPath(id);
|
|
266
|
+
if (!logPath || !fs.existsSync(logPath)) {
|
|
267
|
+
send(socket, { type: "ok", data: "" });
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const offset = record.logOffset;
|
|
271
|
+
const stat = fs.statSync(logPath);
|
|
272
|
+
if (stat.size > offset) {
|
|
273
|
+
const fd = fs.openSync(logPath, "r");
|
|
274
|
+
const buf = Buffer.alloc(stat.size - offset);
|
|
275
|
+
fs.readSync(fd, buf, 0, buf.length, offset);
|
|
276
|
+
fs.closeSync(fd);
|
|
277
|
+
for (const line of buf.toString().split("\n")) {
|
|
278
|
+
if (line) {
|
|
279
|
+
send(socket, { type: "data", line });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (record.status === "completed") {
|
|
284
|
+
send(socket, { type: "end" });
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
let fileSize = stat.size;
|
|
288
|
+
const watcher = fs.watch(logPath, (eventType) => {
|
|
289
|
+
if (eventType === "rename" && !fs.existsSync(logPath)) {
|
|
290
|
+
watcher.close();
|
|
291
|
+
send(socket, { type: "end" });
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (eventType === "change") {
|
|
295
|
+
try {
|
|
296
|
+
const s = fs.statSync(logPath);
|
|
297
|
+
if (s.size > fileSize) {
|
|
298
|
+
const fd = fs.openSync(logPath, "r");
|
|
299
|
+
const buf = Buffer.alloc(s.size - fileSize);
|
|
300
|
+
fs.readSync(fd, buf, 0, buf.length, fileSize);
|
|
301
|
+
fs.closeSync(fd);
|
|
302
|
+
fileSize = s.size;
|
|
303
|
+
for (const line of buf.toString().split("\n")) {
|
|
304
|
+
if (line) {
|
|
305
|
+
send(socket, { type: "data", line });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
watcher.close();
|
|
312
|
+
send(socket, { type: "end" });
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
socket.on("close", () => watcher.close());
|
|
317
|
+
socket.on("error", () => watcher.close());
|
|
147
318
|
}
|
|
148
319
|
handleLogs(request, socket) {
|
|
149
320
|
const id = request.payload.id;
|
package/dist/daemon/state.js
CHANGED
|
@@ -3,7 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import crypto from "node:crypto";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { removeIfExists, writeFileAtomic } from "../shared/fs-utils.js";
|
|
6
|
-
import { getLoopsDir, getLogsDir, loopFile, logFile, getPidFile, getSignatureFile, getSocketPath, } from "../config/paths.js";
|
|
6
|
+
import { getLoopsDir, getTasksDir, getLogsDir, loopFile, taskFile, logFile, getPidFile, getSignatureFile, getSocketPath, } from "../config/paths.js";
|
|
7
7
|
export { getDataDir, getPidFile, getSocketPath } from "../config/paths.js";
|
|
8
8
|
function ensureDirs() {
|
|
9
9
|
fs.mkdirSync(getLoopsDir(), { recursive: true });
|
|
@@ -40,6 +40,37 @@ export function deleteLoop(id) {
|
|
|
40
40
|
removeIfExists(loopFile(id));
|
|
41
41
|
removeIfExists(logFile(id));
|
|
42
42
|
}
|
|
43
|
+
export function saveTask(task) {
|
|
44
|
+
const dir = getTasksDir();
|
|
45
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
46
|
+
writeFileAtomic(taskFile(task.id), JSON.stringify(task, null, 2));
|
|
47
|
+
}
|
|
48
|
+
export function loadTask(id) {
|
|
49
|
+
const file = taskFile(id);
|
|
50
|
+
if (!fs.existsSync(file))
|
|
51
|
+
return null;
|
|
52
|
+
const raw = fs.readFileSync(file, "utf-8");
|
|
53
|
+
return JSON.parse(raw);
|
|
54
|
+
}
|
|
55
|
+
export function loadAllTasks() {
|
|
56
|
+
const dir = getTasksDir();
|
|
57
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
58
|
+
const files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
|
|
59
|
+
const tasks = [];
|
|
60
|
+
for (const file of files) {
|
|
61
|
+
try {
|
|
62
|
+
const raw = fs.readFileSync(path.join(dir, file), "utf-8");
|
|
63
|
+
tasks.push(JSON.parse(raw));
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// skip corrupted files
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return tasks.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
70
|
+
}
|
|
71
|
+
export function deleteTask(id) {
|
|
72
|
+
removeIfExists(taskFile(id));
|
|
73
|
+
}
|
|
43
74
|
export function getLogPath(id) {
|
|
44
75
|
ensureDirs();
|
|
45
76
|
return logFile(id);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { saveTask, loadAllTasks, deleteTask as deleteTaskState } from "./state.js";
|
|
3
|
+
import { daemonLog } from "./daemon-log.js";
|
|
4
|
+
export class TaskManager {
|
|
5
|
+
tasks = new Map();
|
|
6
|
+
init() {
|
|
7
|
+
const saved = loadAllTasks();
|
|
8
|
+
for (const task of saved) {
|
|
9
|
+
this.tasks.set(task.id, task);
|
|
10
|
+
}
|
|
11
|
+
if (saved.length > 0) {
|
|
12
|
+
daemonLog(`loaded ${saved.length} task(s) from persisted state`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
create(input) {
|
|
16
|
+
const task = {
|
|
17
|
+
...input,
|
|
18
|
+
createdAt: new Date().toISOString(),
|
|
19
|
+
};
|
|
20
|
+
this.tasks.set(task.id, task);
|
|
21
|
+
saveTask(task);
|
|
22
|
+
return task;
|
|
23
|
+
}
|
|
24
|
+
get(id) {
|
|
25
|
+
return this.tasks.get(id) ?? null;
|
|
26
|
+
}
|
|
27
|
+
list() {
|
|
28
|
+
return [...this.tasks.values()];
|
|
29
|
+
}
|
|
30
|
+
update(id, input) {
|
|
31
|
+
const existing = this.tasks.get(id);
|
|
32
|
+
if (!existing)
|
|
33
|
+
return null;
|
|
34
|
+
const updated = { ...existing, ...input };
|
|
35
|
+
this.tasks.set(id, updated);
|
|
36
|
+
saveTask(updated);
|
|
37
|
+
return updated;
|
|
38
|
+
}
|
|
39
|
+
delete(id) {
|
|
40
|
+
if (!this.tasks.has(id))
|
|
41
|
+
return false;
|
|
42
|
+
this.tasks.delete(id);
|
|
43
|
+
deleteTaskState(id);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
createInline(command, commandArgs) {
|
|
47
|
+
const id = crypto.randomUUID().slice(0, 8);
|
|
48
|
+
const name = [command, ...commandArgs].join(" ").slice(0, 40);
|
|
49
|
+
return this.create({
|
|
50
|
+
id,
|
|
51
|
+
name,
|
|
52
|
+
command,
|
|
53
|
+
commandArgs,
|
|
54
|
+
onSuccessTaskId: null,
|
|
55
|
+
onFailureTaskId: null,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|