toolcraft 0.0.23 → 0.0.25
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 +2 -2
- package/dist/cli.compile-check.js +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +50 -13
- package/dist/error-report.js +32 -3
- package/dist/human-in-loop/approval-tasks.d.ts +1 -0
- package/dist/human-in-loop/approval-tasks.js +7 -5
- package/dist/human-in-loop/approvals-commands.js +51 -8
- package/dist/human-in-loop/runner.js +24 -19
- package/dist/human-in-loop/state-machine.d.ts +3 -3
- package/dist/human-in-loop/state-machine.js +13 -5
- package/dist/index.d.ts +5 -0
- package/dist/index.js +6 -1
- package/dist/mcp-proxy.js +85 -19
- package/dist/mcp.compile-check.js +1 -0
- package/dist/mcp.d.ts +1 -0
- package/dist/mcp.js +50 -8
- package/dist/renderer.js +119 -13
- package/dist/sdk.compile-check.js +1 -0
- package/dist/sdk.d.ts +1 -0
- package/dist/sdk.js +56 -11
- package/node_modules/@poe-code/agent-defs/dist/registry.d.ts +1 -1
- package/node_modules/@poe-code/agent-defs/dist/registry.js +22 -11
- package/node_modules/@poe-code/agent-defs/package.json +1 -1
- package/node_modules/@poe-code/agent-human-in-loop/dist/providers/osascript-script.js +5 -1
- package/node_modules/@poe-code/agent-human-in-loop/dist/providers/osascript.js +1 -1
- package/node_modules/@poe-code/agent-human-in-loop/package.json +1 -1
- package/node_modules/@poe-code/agent-mcp-config/dist/apply.d.ts +1 -1
- package/node_modules/@poe-code/agent-mcp-config/dist/apply.js +41 -92
- package/node_modules/@poe-code/agent-mcp-config/dist/configs.js +4 -1
- package/node_modules/@poe-code/agent-mcp-config/dist/shapes.d.ts +14 -2
- package/node_modules/@poe-code/agent-mcp-config/dist/shapes.js +11 -4
- package/node_modules/@poe-code/agent-mcp-config/package.json +1 -1
- package/node_modules/@poe-code/config-mutations/dist/execution/apply-mutation.js +200 -22
- package/node_modules/@poe-code/config-mutations/dist/execution/path-utils.js +7 -1
- package/node_modules/@poe-code/config-mutations/dist/formats/index.js +1 -1
- package/node_modules/@poe-code/config-mutations/dist/formats/json.js +11 -7
- package/node_modules/@poe-code/config-mutations/dist/formats/object.d.ts +4 -0
- package/node_modules/@poe-code/config-mutations/dist/formats/object.js +27 -0
- package/node_modules/@poe-code/config-mutations/dist/formats/toml.js +12 -9
- package/node_modules/@poe-code/config-mutations/dist/formats/yaml.js +12 -9
- package/node_modules/@poe-code/config-mutations/dist/mutations/file-mutation.d.ts +11 -1
- package/node_modules/@poe-code/config-mutations/dist/mutations/file-mutation.js +10 -1
- package/node_modules/@poe-code/config-mutations/dist/testing/mock-fs.js +25 -1
- package/node_modules/@poe-code/config-mutations/dist/types.d.ts +12 -2
- package/node_modules/@poe-code/config-mutations/package.json +1 -1
- package/node_modules/@poe-code/design-system/dist/acp/components.js +3 -1
- package/node_modules/@poe-code/design-system/dist/components/browser.d.ts +1 -1
- package/node_modules/@poe-code/design-system/dist/components/browser.js +6 -1
- package/node_modules/@poe-code/design-system/dist/components/color.js +9 -8
- package/node_modules/@poe-code/design-system/dist/components/command-errors.js +3 -2
- package/node_modules/@poe-code/design-system/dist/components/detail-card.d.ts +22 -0
- package/node_modules/@poe-code/design-system/dist/components/detail-card.js +69 -0
- package/node_modules/@poe-code/design-system/dist/components/help-formatter.js +88 -11
- package/node_modules/@poe-code/design-system/dist/components/index.d.ts +1 -1
- package/node_modules/@poe-code/design-system/dist/components/index.js +1 -1
- package/node_modules/@poe-code/design-system/dist/components/table.d.ts +2 -0
- package/node_modules/@poe-code/design-system/dist/components/table.js +82 -5
- package/node_modules/@poe-code/design-system/dist/components/template.d.ts +4 -0
- package/node_modules/@poe-code/design-system/dist/components/template.js +198 -32
- package/node_modules/@poe-code/design-system/dist/components/text.js +29 -5
- package/node_modules/@poe-code/design-system/dist/dashboard/ansi.d.ts +2 -2
- package/node_modules/@poe-code/design-system/dist/dashboard/ansi.js +77 -32
- package/node_modules/@poe-code/design-system/dist/dashboard/buffer.js +28 -5
- package/node_modules/@poe-code/design-system/dist/dashboard/components/output-pane.js +45 -28
- package/node_modules/@poe-code/design-system/dist/dashboard/terminal-width.d.ts +4 -0
- package/node_modules/@poe-code/design-system/dist/dashboard/terminal-width.js +71 -0
- package/node_modules/@poe-code/design-system/dist/dashboard/types.d.ts +1 -0
- package/node_modules/@poe-code/design-system/dist/explorer/events.d.ts +6 -0
- package/node_modules/@poe-code/design-system/dist/explorer/reducer.js +32 -10
- package/node_modules/@poe-code/design-system/dist/explorer/render/detail.js +3 -0
- package/node_modules/@poe-code/design-system/dist/explorer/runtime.js +57 -6
- package/node_modules/@poe-code/design-system/dist/explorer/state.d.ts +1 -0
- package/node_modules/@poe-code/design-system/dist/explorer/state.js +12 -15
- package/node_modules/@poe-code/design-system/dist/index.d.ts +3 -1
- package/node_modules/@poe-code/design-system/dist/index.js +2 -1
- package/node_modules/@poe-code/design-system/dist/prompts/primitives/intro.js +2 -1
- package/node_modules/@poe-code/design-system/dist/prompts/primitives/log.js +8 -5
- package/node_modules/@poe-code/design-system/dist/prompts/primitives/note.js +1 -1
- package/node_modules/@poe-code/design-system/dist/static/menu.js +8 -2
- package/node_modules/@poe-code/design-system/dist/static/spinner.js +10 -4
- package/node_modules/@poe-code/design-system/dist/terminal-markdown/parser/frontmatter.js +9 -2
- package/node_modules/@poe-code/design-system/dist/terminal-markdown/renderer.js +19 -2
- package/node_modules/@poe-code/design-system/package.json +2 -1
- package/node_modules/@poe-code/process-runner/dist/docker/args.d.ts +1 -0
- package/node_modules/@poe-code/process-runner/dist/docker/args.js +11 -3
- package/node_modules/@poe-code/process-runner/dist/docker/docker-execution-env.js +377 -130
- package/node_modules/@poe-code/process-runner/dist/docker/docker-runner.js +78 -10
- package/node_modules/@poe-code/process-runner/dist/docker/env-file.d.ts +6 -0
- package/node_modules/@poe-code/process-runner/dist/docker/env-file.js +49 -0
- package/node_modules/@poe-code/process-runner/dist/host/host-execution-env.js +3 -2
- package/node_modules/@poe-code/process-runner/dist/host/host-runner.js +21 -5
- package/node_modules/@poe-code/process-runner/dist/index.d.ts +1 -0
- package/node_modules/@poe-code/process-runner/dist/index.js +1 -0
- package/node_modules/@poe-code/process-runner/dist/testing/mock-runner.js +30 -8
- package/node_modules/@poe-code/process-runner/dist/types.d.ts +6 -0
- package/node_modules/@poe-code/process-runner/dist/workspace-transfer.d.ts +61 -0
- package/node_modules/@poe-code/process-runner/dist/workspace-transfer.js +503 -0
- package/node_modules/@poe-code/process-runner/package.json +1 -1
- package/node_modules/@poe-code/task-list/README.md +0 -2
- package/node_modules/@poe-code/task-list/dist/backends/gh-issues-client.js +3 -0
- package/node_modules/@poe-code/task-list/dist/backends/gh-issues-sync.js +89 -59
- package/node_modules/@poe-code/task-list/dist/backends/gh-issues.d.ts +9 -3
- package/node_modules/@poe-code/task-list/dist/backends/gh-issues.js +460 -99
- package/node_modules/@poe-code/task-list/dist/backends/markdown-dir.js +156 -154
- package/node_modules/@poe-code/task-list/dist/backends/utils.d.ts +2 -0
- package/node_modules/@poe-code/task-list/dist/backends/utils.js +79 -0
- package/node_modules/@poe-code/task-list/dist/backends/yaml-file.js +120 -132
- package/node_modules/@poe-code/task-list/dist/index.d.ts +3 -1
- package/node_modules/@poe-code/task-list/dist/index.js +2 -0
- package/node_modules/@poe-code/task-list/dist/move.d.ts +2 -0
- package/node_modules/@poe-code/task-list/dist/move.js +215 -0
- package/node_modules/@poe-code/task-list/dist/open.js +3 -4
- package/node_modules/@poe-code/task-list/dist/state-machine.js +3 -1
- package/node_modules/@poe-code/task-list/dist/state.js +9 -0
- package/node_modules/@poe-code/task-list/dist/types.d.ts +48 -13
- package/node_modules/@poe-code/task-list/package.json +1 -2
- package/node_modules/auth-store/dist/create-secret-store.js +4 -1
- package/node_modules/auth-store/dist/encrypted-file-store.d.ts +8 -0
- package/node_modules/auth-store/dist/encrypted-file-store.js +104 -8
- package/node_modules/auth-store/dist/index.d.ts +1 -1
- package/node_modules/auth-store/dist/keychain-store.d.ts +4 -1
- package/node_modules/auth-store/dist/keychain-store.js +18 -16
- package/node_modules/auth-store/dist/provider-store.d.ts +5 -1
- package/node_modules/auth-store/dist/provider-store.js +55 -7
- package/node_modules/auth-store/dist/types.d.ts +3 -1
- package/node_modules/auth-store/package.json +2 -1
- package/node_modules/mcp-oauth/dist/client/default-oauth-client-provider.js +46 -15
- package/node_modules/mcp-oauth/dist/client/loopback-authorization.js +49 -12
- package/node_modules/mcp-oauth/dist/client/token-endpoint.js +6 -1
- package/node_modules/mcp-oauth/dist/server/jwks-token-verifier.js +1 -1
- package/node_modules/mcp-oauth/package.json +1 -0
- package/node_modules/tiny-mcp-client/.turbo/turbo-build.log +1 -1
- package/node_modules/tiny-mcp-client/dist/internal.d.ts +9 -4
- package/node_modules/tiny-mcp-client/dist/internal.js +244 -66
- package/node_modules/tiny-mcp-client/dist/oauth-discovery.d.ts +1 -1
- package/node_modules/tiny-mcp-client/dist/oauth-discovery.js +4 -7
- package/node_modules/tiny-mcp-client/package.json +2 -1
- package/node_modules/tiny-mcp-client/src/http-oauth.integration.test.ts +1 -1
- package/node_modules/tiny-mcp-client/src/http-oauth.test.ts +46 -0
- package/node_modules/tiny-mcp-client/src/internal.ts +287 -76
- package/node_modules/tiny-mcp-client/src/mcp-client-sdk.test.ts +32 -0
- package/node_modules/tiny-mcp-client/src/mcp-client-tiny-stdio-test-server-tools.test.ts +1 -1
- package/node_modules/tiny-mcp-client/src/oauth-discovery.ts +5 -10
- package/node_modules/tiny-mcp-client/src/transports.test.ts +588 -6
- package/package.json +10 -12
- package/node_modules/@poe-code/file-lock/README.md +0 -52
- package/node_modules/@poe-code/file-lock/dist/index.d.ts +0 -1
- package/node_modules/@poe-code/file-lock/dist/index.js +0 -1
- package/node_modules/@poe-code/file-lock/dist/lock.d.ts +0 -27
- package/node_modules/@poe-code/file-lock/dist/lock.js +0 -203
- package/node_modules/@poe-code/file-lock/package.json +0 -23
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import { acquireFileLock } from "@poe-code/file-lock";
|
|
3
2
|
import { isMap, parseDocument } from "yaml";
|
|
4
3
|
import storeSchema from "../schema/store.schema.json" with { type: "json" };
|
|
5
4
|
import taskSchema from "../schema/task.schema.json" with { type: "json" };
|
|
6
5
|
import { eventsFromState, findEvent } from "../state-machine.js";
|
|
7
6
|
import { resolveStateMachine } from "../state.js";
|
|
8
7
|
import { AnchorNotFoundError, InvalidTransitionError, MalformedTaskError, OrderMismatchError, TaskAlreadyExistsError, TaskNotFoundError } from "../types.js";
|
|
9
|
-
import { applyOrder, isRecord, sortStrings, statIfExists, validateTaskId, writeAtomically } from "./utils.js";
|
|
8
|
+
import { applyOrder, isRecord, rejectSymbolicLinkComponents, sortStrings, statIfExists, validateTaskId, withFileLock, writeAtomically } from "./utils.js";
|
|
10
9
|
const STORE_KIND = "task-store";
|
|
11
10
|
const STORE_SCHEMA_ID = storeSchema.$id;
|
|
12
11
|
const STORE_VERSION = 1;
|
|
@@ -54,7 +53,7 @@ function descriptionFromTaskRecord(taskRecord) {
|
|
|
54
53
|
return typeof taskRecord.description === "string" ? taskRecord.description : "";
|
|
55
54
|
}
|
|
56
55
|
function metadataFromTaskRecord(taskRecord) {
|
|
57
|
-
const metadata =
|
|
56
|
+
const metadata = Object.create(null);
|
|
58
57
|
for (const [key, value] of Object.entries(taskRecord)) {
|
|
59
58
|
if (!RESERVED_TASK_KEYS.has(key)) {
|
|
60
59
|
metadata[key] = value;
|
|
@@ -84,11 +83,11 @@ function matchesFilter(task, filter) {
|
|
|
84
83
|
return true;
|
|
85
84
|
}
|
|
86
85
|
function createTaskRecord(defaults, input, initialState) {
|
|
87
|
-
const taskRecord = {
|
|
86
|
+
const taskRecord = Object.assign(Object.create(null), {
|
|
88
87
|
name: input.name,
|
|
89
88
|
state: initialState,
|
|
90
89
|
description: input.description ?? ""
|
|
91
|
-
};
|
|
90
|
+
});
|
|
92
91
|
for (const [key, value] of Object.entries(defaults.metadata)) {
|
|
93
92
|
if (!RESERVED_TASK_KEYS.has(key)) {
|
|
94
93
|
taskRecord[key] = value;
|
|
@@ -118,12 +117,12 @@ function assertUpdateDoesNotSetState(patch) {
|
|
|
118
117
|
}
|
|
119
118
|
}
|
|
120
119
|
function buildUpdatedTaskRecord(existing, patch) {
|
|
121
|
-
const nextTaskRecord = {
|
|
120
|
+
const nextTaskRecord = Object.assign(Object.create(null), existing, {
|
|
122
121
|
...existing,
|
|
123
122
|
name: patch.name ?? existing.name,
|
|
124
123
|
state: existing.state,
|
|
125
124
|
description: patch.description ?? descriptionFromTaskRecord(existing)
|
|
126
|
-
};
|
|
125
|
+
});
|
|
127
126
|
for (const [key, value] of Object.entries(patch.metadata ?? {})) {
|
|
128
127
|
if (!RESERVED_TASK_KEYS.has(key)) {
|
|
129
128
|
nextTaskRecord[key] = value;
|
|
@@ -132,11 +131,11 @@ function buildUpdatedTaskRecord(existing, patch) {
|
|
|
132
131
|
return nextTaskRecord;
|
|
133
132
|
}
|
|
134
133
|
function buildTransitionedTaskRecord(existing, to) {
|
|
135
|
-
return {
|
|
134
|
+
return Object.assign(Object.create(null), existing, {
|
|
136
135
|
...existing,
|
|
137
136
|
state: to,
|
|
138
137
|
description: descriptionFromTaskRecord(existing)
|
|
139
|
-
};
|
|
138
|
+
});
|
|
140
139
|
}
|
|
141
140
|
function buildFiredTaskRecord(existing, to, metadataPatch) {
|
|
142
141
|
const nextTaskRecord = buildTransitionedTaskRecord(existing, to);
|
|
@@ -170,7 +169,9 @@ function assertValidStoreRecord(store, filePath) {
|
|
|
170
169
|
if (store.kind !== STORE_KIND) {
|
|
171
170
|
throw malformedStore(filePath, "kind");
|
|
172
171
|
}
|
|
173
|
-
if (typeof store.version !== "number" ||
|
|
172
|
+
if (typeof store.version !== "number" ||
|
|
173
|
+
!Number.isInteger(store.version) ||
|
|
174
|
+
store.version !== STORE_VERSION) {
|
|
174
175
|
throw malformedStore(filePath, "version");
|
|
175
176
|
}
|
|
176
177
|
if (!isRecord(store.lists)) {
|
|
@@ -254,7 +255,9 @@ function getListRecord(store, list) {
|
|
|
254
255
|
}
|
|
255
256
|
function getTaskRecord(store, list, id) {
|
|
256
257
|
const listRecord = getListRecord(store, list);
|
|
257
|
-
const taskRecord = listRecord
|
|
258
|
+
const taskRecord = listRecord !== undefined && Object.prototype.hasOwnProperty.call(listRecord, id)
|
|
259
|
+
? listRecord[id]
|
|
260
|
+
: undefined;
|
|
258
261
|
return isRecord(taskRecord) ? taskRecord : undefined;
|
|
259
262
|
}
|
|
260
263
|
function getTaskOrThrow(store, list, id) {
|
|
@@ -280,7 +283,10 @@ function pairKey(pair) {
|
|
|
280
283
|
if (typeof key === "string") {
|
|
281
284
|
return key;
|
|
282
285
|
}
|
|
283
|
-
if (key &&
|
|
286
|
+
if (key &&
|
|
287
|
+
typeof key === "object" &&
|
|
288
|
+
"value" in key &&
|
|
289
|
+
typeof key.value === "string") {
|
|
284
290
|
return key.value;
|
|
285
291
|
}
|
|
286
292
|
return undefined;
|
|
@@ -296,7 +302,10 @@ function activeItemIds(listNode, validStates) {
|
|
|
296
302
|
continue;
|
|
297
303
|
const value = pair.value;
|
|
298
304
|
let state;
|
|
299
|
-
if (value &&
|
|
305
|
+
if (value &&
|
|
306
|
+
typeof value === "object" &&
|
|
307
|
+
"get" in value &&
|
|
308
|
+
typeof value.get === "function") {
|
|
300
309
|
state = value.get("state");
|
|
301
310
|
}
|
|
302
311
|
else if (isRecord(value)) {
|
|
@@ -309,6 +318,7 @@ function activeItemIds(listNode, validStates) {
|
|
|
309
318
|
return ids;
|
|
310
319
|
}
|
|
311
320
|
async function ensureStorePath(deps) {
|
|
321
|
+
await rejectSymbolicLinkComponents(deps.fs, deps.path);
|
|
312
322
|
if (!deps.create) {
|
|
313
323
|
await deps.fs.stat(deps.path);
|
|
314
324
|
return;
|
|
@@ -325,19 +335,6 @@ async function ensureStorePath(deps) {
|
|
|
325
335
|
""
|
|
326
336
|
].join("\n"))));
|
|
327
337
|
}
|
|
328
|
-
async function withStoreLock(deps, action) {
|
|
329
|
-
const release = await acquireFileLock(deps.path, {
|
|
330
|
-
fs: deps.fs,
|
|
331
|
-
staleMs: deps.lockStaleMs,
|
|
332
|
-
retries: deps.lockRetries
|
|
333
|
-
});
|
|
334
|
-
try {
|
|
335
|
-
return await action();
|
|
336
|
-
}
|
|
337
|
-
finally {
|
|
338
|
-
await release();
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
338
|
function createTasksView(deps, list) {
|
|
342
339
|
const stateMachine = resolveStateMachine(deps.stateMachine);
|
|
343
340
|
const validStates = new Set(stateMachine.states);
|
|
@@ -382,42 +379,38 @@ function createTasksView(deps, list) {
|
|
|
382
379
|
assertCreateDoesNotSetState(input);
|
|
383
380
|
assertCreateHasId(input);
|
|
384
381
|
validateTaskId(input.id);
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
return createTask(list, input.id, taskRecord, deps.path);
|
|
394
|
-
});
|
|
382
|
+
const { document, store } = await readStore(deps.fs, deps.path, validStates);
|
|
383
|
+
if (getTaskRecord(store, list, input.id)) {
|
|
384
|
+
throw new TaskAlreadyExistsError(`Task "${list}/${input.id}" already exists.`);
|
|
385
|
+
}
|
|
386
|
+
const taskRecord = createTaskRecord(deps.defaults, input, stateMachine.initial);
|
|
387
|
+
document.setIn(["lists", list, input.id], taskRecord);
|
|
388
|
+
await writeAtomically(deps.fs, deps.path, serializeDocument(document));
|
|
389
|
+
return createTask(list, input.id, taskRecord, deps.path);
|
|
395
390
|
},
|
|
396
391
|
async update(id, patch) {
|
|
397
392
|
assertUpdateDoesNotSetState(patch);
|
|
398
393
|
validateTaskId(id);
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
document.setIn(["lists", list, id, key], value);
|
|
412
|
-
}
|
|
394
|
+
const { document, store } = await readStore(deps.fs, deps.path, validStates);
|
|
395
|
+
const existing = getTaskOrThrow(store, list, id);
|
|
396
|
+
const nextTaskRecord = buildUpdatedTaskRecord(existing, patch);
|
|
397
|
+
if (patch.name !== undefined) {
|
|
398
|
+
document.setIn(["lists", list, id, "name"], patch.name);
|
|
399
|
+
}
|
|
400
|
+
if (patch.description !== undefined) {
|
|
401
|
+
document.setIn(["lists", list, id, "description"], patch.description);
|
|
402
|
+
}
|
|
403
|
+
for (const [key, value] of Object.entries(patch.metadata ?? {})) {
|
|
404
|
+
if (!RESERVED_TASK_KEYS.has(key)) {
|
|
405
|
+
document.setIn(["lists", list, id, key], value);
|
|
413
406
|
}
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
407
|
+
}
|
|
408
|
+
await writeAtomically(deps.fs, deps.path, serializeDocument(document));
|
|
409
|
+
return createTask(list, id, nextTaskRecord, deps.path);
|
|
417
410
|
},
|
|
418
411
|
async fire(id, eventName, opts) {
|
|
419
412
|
validateTaskId(id);
|
|
420
|
-
|
|
413
|
+
const { event, nextTask } = await withFileLock(deps.fs, `${deps.path}.lock`, async () => {
|
|
421
414
|
const { document, store } = await readStore(deps.fs, deps.path, validStates);
|
|
422
415
|
const existing = getTaskOrThrow(store, list, id);
|
|
423
416
|
const task = createTask(list, id, existing, deps.path);
|
|
@@ -440,10 +433,13 @@ function createTasksView(deps, list) {
|
|
|
440
433
|
}
|
|
441
434
|
}
|
|
442
435
|
await writeAtomically(deps.fs, deps.path, serializeDocument(document));
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
436
|
+
return {
|
|
437
|
+
event,
|
|
438
|
+
nextTask: createTask(list, id, nextTaskRecord, deps.path)
|
|
439
|
+
};
|
|
446
440
|
});
|
|
441
|
+
await event.onEnter?.(nextTask);
|
|
442
|
+
return nextTask;
|
|
447
443
|
},
|
|
448
444
|
async canFire(id, eventName) {
|
|
449
445
|
validateTaskId(id);
|
|
@@ -463,79 +459,73 @@ function createTasksView(deps, list) {
|
|
|
463
459
|
},
|
|
464
460
|
async delete(id) {
|
|
465
461
|
validateTaskId(id);
|
|
466
|
-
await
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
await writeAtomically(deps.fs, deps.path, serializeDocument(document));
|
|
471
|
-
});
|
|
462
|
+
const { document, store } = await readStore(deps.fs, deps.path, validStates);
|
|
463
|
+
getTaskOrThrow(store, list, id);
|
|
464
|
+
document.deleteIn(["lists", list, id]);
|
|
465
|
+
await writeAtomically(deps.fs, deps.path, serializeDocument(document));
|
|
472
466
|
},
|
|
473
467
|
async move(id, anchor) {
|
|
474
468
|
validateTaskId(id);
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
throw new AnchorNotFoundError(anchorId);
|
|
497
|
-
}
|
|
498
|
-
insertIndex = "before" in anchor ? anchorIndex : anchorIndex + 1;
|
|
469
|
+
const { document, store } = await readStore(deps.fs, deps.path, validStates);
|
|
470
|
+
const taskRecord = getTaskOrThrow(store, list, id);
|
|
471
|
+
const listNode = getListNode(document, list);
|
|
472
|
+
if (!listNode) {
|
|
473
|
+
throw new TaskNotFoundError(`Task "${list}/${id}" not found.`);
|
|
474
|
+
}
|
|
475
|
+
const fromIndex = findItemIndex(listNode, id);
|
|
476
|
+
if (fromIndex < 0) {
|
|
477
|
+
throw new TaskNotFoundError(`Task "${list}/${id}" not found.`);
|
|
478
|
+
}
|
|
479
|
+
const [movedPair] = listNode.items.splice(fromIndex, 1);
|
|
480
|
+
let insertIndex;
|
|
481
|
+
if ("position" in anchor) {
|
|
482
|
+
insertIndex = anchor.position === "top" ? 0 : listNode.items.length;
|
|
483
|
+
}
|
|
484
|
+
else {
|
|
485
|
+
const anchorId = "before" in anchor ? anchor.before : anchor.after;
|
|
486
|
+
const anchorIndex = findItemIndex(listNode, anchorId);
|
|
487
|
+
if (anchorIndex < 0) {
|
|
488
|
+
listNode.items.splice(fromIndex, 0, movedPair);
|
|
489
|
+
throw new AnchorNotFoundError(anchorId);
|
|
499
490
|
}
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
491
|
+
insertIndex = "before" in anchor ? anchorIndex : anchorIndex + 1;
|
|
492
|
+
}
|
|
493
|
+
listNode.items.splice(insertIndex, 0, movedPair);
|
|
494
|
+
await writeAtomically(deps.fs, deps.path, serializeDocument(document));
|
|
495
|
+
return createTask(list, id, taskRecord, deps.path);
|
|
504
496
|
},
|
|
505
497
|
async reorder(ids) {
|
|
506
498
|
for (const id of ids) {
|
|
507
499
|
validateTaskId(id);
|
|
508
500
|
}
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
501
|
+
const { document, store } = await readStore(deps.fs, deps.path, validStates);
|
|
502
|
+
const listNode = getListNode(document, list);
|
|
503
|
+
if (!listNode) {
|
|
504
|
+
throw new OrderMismatchError({ missing: [...ids], extra: [] });
|
|
505
|
+
}
|
|
506
|
+
const currentActive = activeItemIds(listNode, validStates);
|
|
507
|
+
const currentSet = new Set(currentActive);
|
|
508
|
+
const inputSet = new Set(ids);
|
|
509
|
+
const missing = currentActive.filter((id) => !inputSet.has(id));
|
|
510
|
+
const extra = ids.filter((id) => !currentSet.has(id));
|
|
511
|
+
if (inputSet.size !== ids.length || missing.length > 0 || extra.length > 0) {
|
|
512
|
+
throw new OrderMismatchError({ missing, extra });
|
|
513
|
+
}
|
|
514
|
+
const archivedPairs = listNode.items.filter((pair) => {
|
|
515
|
+
const id = pairKey(pair);
|
|
516
|
+
return id !== undefined && !currentSet.has(id);
|
|
517
|
+
});
|
|
518
|
+
const orderedActive = ids.map((id) => {
|
|
519
|
+
const pair = listNode.items.find((p) => pairKey(p) === id);
|
|
520
|
+
if (!pair) {
|
|
521
|
+
throw new OrderMismatchError({ missing: [id], extra: [] });
|
|
522
522
|
}
|
|
523
|
-
|
|
524
|
-
const id = pairKey(pair);
|
|
525
|
-
return id !== undefined && !currentSet.has(id);
|
|
526
|
-
});
|
|
527
|
-
const orderedActive = ids.map((id) => {
|
|
528
|
-
const pair = listNode.items.find((p) => pairKey(p) === id);
|
|
529
|
-
if (!pair) {
|
|
530
|
-
throw new OrderMismatchError({ missing: [id], extra: [] });
|
|
531
|
-
}
|
|
532
|
-
return pair;
|
|
533
|
-
});
|
|
534
|
-
listNode.items.splice(0, listNode.items.length, ...orderedActive, ...archivedPairs);
|
|
535
|
-
await writeAtomically(deps.fs, deps.path, serializeDocument(document));
|
|
536
|
-
const tasks = ids.map((id) => createTask(list, id, getTaskOrThrow(store, list, id), deps.path));
|
|
537
|
-
return tasks;
|
|
523
|
+
return pair;
|
|
538
524
|
});
|
|
525
|
+
listNode.items.splice(0, listNode.items.length, ...orderedActive, ...archivedPairs);
|
|
526
|
+
await writeAtomically(deps.fs, deps.path, serializeDocument(document));
|
|
527
|
+
const tasks = ids.map((id) => createTask(list, id, getTaskOrThrow(store, list, id), deps.path));
|
|
528
|
+
return tasks;
|
|
539
529
|
}
|
|
540
530
|
};
|
|
541
531
|
}
|
|
@@ -576,20 +566,18 @@ export async function yamlFileBackend(deps) {
|
|
|
576
566
|
const moveBetweenLists = async (qualifiedId, targetList) => {
|
|
577
567
|
const { list: sourceListName, id } = parseQualifiedId(qualifiedId);
|
|
578
568
|
const targetListName = validateListName(targetList);
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
if (sourceListName === targetListName) {
|
|
583
|
-
return createTask(targetListName, id, taskRecord, deps.path);
|
|
584
|
-
}
|
|
585
|
-
if (getTaskRecord(store, targetListName, id)) {
|
|
586
|
-
throw new TaskAlreadyExistsError(`Task "${targetListName}/${id}" already exists.`);
|
|
587
|
-
}
|
|
588
|
-
document.deleteIn(["lists", sourceListName, id]);
|
|
589
|
-
document.setIn(["lists", targetListName, id], taskRecord);
|
|
590
|
-
await writeAtomically(deps.fs, deps.path, serializeDocument(document));
|
|
569
|
+
const { document, store } = await readStore(deps.fs, deps.path, validStates);
|
|
570
|
+
const taskRecord = getTaskOrThrow(store, sourceListName, id);
|
|
571
|
+
if (sourceListName === targetListName) {
|
|
591
572
|
return createTask(targetListName, id, taskRecord, deps.path);
|
|
592
|
-
}
|
|
573
|
+
}
|
|
574
|
+
if (getTaskRecord(store, targetListName, id)) {
|
|
575
|
+
throw new TaskAlreadyExistsError(`Task "${targetListName}/${id}" already exists.`);
|
|
576
|
+
}
|
|
577
|
+
document.deleteIn(["lists", sourceListName, id]);
|
|
578
|
+
document.setIn(["lists", targetListName, id], taskRecord);
|
|
579
|
+
await writeAtomically(deps.fs, deps.path, serializeDocument(document));
|
|
580
|
+
return createTask(targetListName, id, taskRecord, deps.path);
|
|
593
581
|
};
|
|
594
582
|
return {
|
|
595
583
|
list,
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
export { openTaskList } from "./open.js";
|
|
2
|
+
export { moveTasks } from "./move.js";
|
|
2
3
|
export { assertEvent, assertTransition, defaultStateMachine, type TaskEvent } from "./state.js";
|
|
3
4
|
export { eventsFromState, findEvent, validateMachine, type EventDef, type StateMachineDef } from "./state-machine.js";
|
|
4
|
-
export { AnchorNotFoundError, InvalidTransitionError, MalformedTaskError, OrderMismatchError, TaskAlreadyExistsError, TaskNotFoundError, type ListFilter, type MoveAnchor, type OpenGhIssuesOptions, type OpenMarkdownDirOptions, type OpenTaskListOptions, type OpenYamlFileOptions, type Task, type TaskCreate, type TaskDefaults, type TaskFireOptions, type TaskList, type TaskListFs, type TaskOrder, type TaskState, type Tasks, type TaskUpdate } from "./types.js";
|
|
5
|
+
export { AnchorNotFoundError, InvalidTransitionError, MalformedTaskError, OrderMismatchError, TaskAlreadyExistsError, TaskNotFoundError, type ListFilter, type MoveProgressEvent, type MoveResult, type MoveTasksOptions, type MoveAnchor, type OpenGhIssuesOptions, type OpenMarkdownDirOptions, type OpenTaskListOptions, type OpenYamlFileOptions, type Task, type TaskCreate, type TaskDefaults, type TaskFireOptions, type TaskList, type TaskListOptions, type TaskListFs, type TaskOrder, type TaskState, type Tasks, type TaskUpdate } from "./types.js";
|
|
6
|
+
export { resolveAuth } from "./backends/gh-issues-client.js";
|
|
5
7
|
export type { GhClient, GhClientOptions, ResolveAuthOptions, ResolveEndpointOptions } from "./backends/gh-issues-client.js";
|
|
6
8
|
export type { GhIssuesBackendDeps } from "./backends/gh-issues.js";
|
|
7
9
|
export { GhProjectSyncError, syncGhProject, verifyGhProject, type SyncGhProjectOptions, type SyncGhProjectReport, type VerifyGhProjectOptions, type VerifyGhProjectReport } from "./backends/gh-issues-sync.js";
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { openTaskList } from "./open.js";
|
|
2
|
+
export { moveTasks } from "./move.js";
|
|
2
3
|
export { assertEvent, assertTransition, defaultStateMachine } from "./state.js";
|
|
3
4
|
export { eventsFromState, findEvent, validateMachine } from "./state-machine.js";
|
|
4
5
|
export { AnchorNotFoundError, InvalidTransitionError, MalformedTaskError, OrderMismatchError, TaskAlreadyExistsError, TaskNotFoundError } from "./types.js";
|
|
6
|
+
export { resolveAuth } from "./backends/gh-issues-client.js";
|
|
5
7
|
export { GhProjectSyncError, syncGhProject, verifyGhProject } from "./backends/gh-issues-sync.js";
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import * as fsPromises from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { findEvent } from "./state-machine.js";
|
|
4
|
+
import { openTaskList } from "./open.js";
|
|
5
|
+
export async function moveTasks(options) {
|
|
6
|
+
const rate = options.rate ?? 15;
|
|
7
|
+
if (!Number.isFinite(rate) || rate <= 0) {
|
|
8
|
+
throw new RangeError("moveTasks rate must be a positive number.");
|
|
9
|
+
}
|
|
10
|
+
if (options.limit !== undefined && (!Number.isInteger(options.limit) || options.limit < 0)) {
|
|
11
|
+
throw new RangeError("moveTasks limit must be a non-negative integer.");
|
|
12
|
+
}
|
|
13
|
+
const source = await openTaskList(readOnlySourceOptions(options.source));
|
|
14
|
+
const tasks = (await source.allTasks({ includeArchived: true })).slice(0, options.limit);
|
|
15
|
+
const result = { created: 0, skipped: 0, errors: [] };
|
|
16
|
+
if (tasks.length === 0) {
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
const target = await openTaskList(readOnlyTargetOptions(options.target, options.dryRun));
|
|
20
|
+
const targetLists = new Map();
|
|
21
|
+
const takeToken = createTokenBucket(rate);
|
|
22
|
+
for (const task of tasks) {
|
|
23
|
+
let targetTasks;
|
|
24
|
+
let targetState;
|
|
25
|
+
let createdTarget;
|
|
26
|
+
try {
|
|
27
|
+
targetTasks = await resolveTargetTasks(task, target, targetLists);
|
|
28
|
+
targetState = options.stateMap?.[task.state] ?? targetTasks.stateMachine.initial;
|
|
29
|
+
const transitionEvents = findTransitionEvents(targetTasks, targetTasks.stateMachine.initial, targetState);
|
|
30
|
+
if (targetTasks.stateMachine.initial !== targetState && transitionEvents === undefined) {
|
|
31
|
+
throw new Error(`Cannot migrate task state from "${targetTasks.stateMachine.initial}" to "${targetState}".`);
|
|
32
|
+
}
|
|
33
|
+
if (options.dryRun) {
|
|
34
|
+
result.skipped += 1;
|
|
35
|
+
emitProgress(options, {
|
|
36
|
+
type: "skipped",
|
|
37
|
+
id: task.id,
|
|
38
|
+
source: task,
|
|
39
|
+
targetList: targetTasks.name,
|
|
40
|
+
targetState,
|
|
41
|
+
reason: "dry-run"
|
|
42
|
+
});
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
await takeToken();
|
|
46
|
+
let created = await targetTasks.create({
|
|
47
|
+
id: task.id,
|
|
48
|
+
name: task.name,
|
|
49
|
+
description: task.description,
|
|
50
|
+
metadata: task.metadata
|
|
51
|
+
});
|
|
52
|
+
createdTarget = created;
|
|
53
|
+
created = await applyState(targetTasks, created, transitionEvents ?? []);
|
|
54
|
+
if (options.deleteSource === true) {
|
|
55
|
+
await source.list(task.list).delete(task.id);
|
|
56
|
+
}
|
|
57
|
+
result.created += 1;
|
|
58
|
+
emitProgress(options, {
|
|
59
|
+
type: "created",
|
|
60
|
+
id: task.id,
|
|
61
|
+
source: task,
|
|
62
|
+
target: created,
|
|
63
|
+
targetList: targetTasks.name,
|
|
64
|
+
targetState
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
let message = errorMessage(error);
|
|
69
|
+
if (createdTarget !== undefined) {
|
|
70
|
+
try {
|
|
71
|
+
await targetTasks.delete(createdTarget.id);
|
|
72
|
+
}
|
|
73
|
+
catch (rollbackError) {
|
|
74
|
+
message = `${message} Rollback failed: ${errorMessage(rollbackError)}`;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
result.errors.push({ id: task.id, error: message });
|
|
78
|
+
emitProgress(options, { type: "error", id: task.id, source: task, error: message });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
function emitProgress(options, event) {
|
|
84
|
+
try {
|
|
85
|
+
options.onProgress?.(event);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async function resolveTargetTasks(sourceTask, target, targetLists) {
|
|
92
|
+
const cached = targetLists.get(sourceTask.list);
|
|
93
|
+
if (cached !== undefined) {
|
|
94
|
+
return cached;
|
|
95
|
+
}
|
|
96
|
+
let tasks;
|
|
97
|
+
try {
|
|
98
|
+
tasks = target.list(sourceTask.list);
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
const lists = await target.lists();
|
|
102
|
+
if (lists.length !== 1) {
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
tasks = target.list(lists[0]);
|
|
106
|
+
}
|
|
107
|
+
targetLists.set(sourceTask.list, tasks);
|
|
108
|
+
return tasks;
|
|
109
|
+
}
|
|
110
|
+
async function applyState(tasks, created, events) {
|
|
111
|
+
let task = created;
|
|
112
|
+
for (const event of events) {
|
|
113
|
+
task = await tasks.fire(task.id, event);
|
|
114
|
+
}
|
|
115
|
+
return task;
|
|
116
|
+
}
|
|
117
|
+
function readOnlySourceOptions(options) {
|
|
118
|
+
if (!("create" in options)) {
|
|
119
|
+
return options;
|
|
120
|
+
}
|
|
121
|
+
return { ...options, create: false };
|
|
122
|
+
}
|
|
123
|
+
function readOnlyTargetOptions(options, dryRun) {
|
|
124
|
+
if (!dryRun || options.type === "gh-issues") {
|
|
125
|
+
return options;
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
...options,
|
|
129
|
+
create: true,
|
|
130
|
+
fs: createDryRunFs(options.fs ?? fsPromises, options.path, options.type)
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
function createDryRunFs(fs, targetPath, targetType) {
|
|
134
|
+
const resolvedTargetPath = path.resolve(targetPath);
|
|
135
|
+
const unexpectedWrite = async () => {
|
|
136
|
+
throw new Error("moveTasks dryRun attempted to write to the target backend.");
|
|
137
|
+
};
|
|
138
|
+
return {
|
|
139
|
+
...fs,
|
|
140
|
+
async mkdir(directoryPath) {
|
|
141
|
+
if (path.resolve(directoryPath) !== resolvedTargetPath) {
|
|
142
|
+
await unexpectedWrite();
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
async stat(filePath) {
|
|
146
|
+
try {
|
|
147
|
+
return await fs.stat(filePath);
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
if (path.resolve(filePath) !== resolvedTargetPath) {
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
isDirectory: () => targetType === "markdown-dir",
|
|
155
|
+
isFile: () => targetType === "yaml-file",
|
|
156
|
+
mtimeMs: 0
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
rename: unexpectedWrite,
|
|
161
|
+
unlink: unexpectedWrite,
|
|
162
|
+
writeFile: unexpectedWrite
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
function findTransitionEvents(tasks, fromState, targetState) {
|
|
166
|
+
if (fromState === targetState) {
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
const queue = [{ state: fromState, events: [] }];
|
|
170
|
+
const visited = new Set([fromState]);
|
|
171
|
+
while (queue.length > 0) {
|
|
172
|
+
const current = queue.shift();
|
|
173
|
+
if (current === undefined) {
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
for (const eventName of Object.keys(tasks.stateMachine.events)) {
|
|
177
|
+
const event = findEvent(tasks.stateMachine, current.state, eventName);
|
|
178
|
+
if (event === undefined) {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const events = [...current.events, eventName];
|
|
182
|
+
if (event.to === targetState) {
|
|
183
|
+
return events;
|
|
184
|
+
}
|
|
185
|
+
if (!visited.has(event.to)) {
|
|
186
|
+
visited.add(event.to);
|
|
187
|
+
queue.push({ state: event.to, events });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
function createTokenBucket(rate) {
|
|
194
|
+
const intervalMs = 60_000 / rate;
|
|
195
|
+
const capacity = Math.max(1, rate);
|
|
196
|
+
let tokens = capacity;
|
|
197
|
+
let lastRefill = Date.now();
|
|
198
|
+
return async () => {
|
|
199
|
+
while (true) {
|
|
200
|
+
const now = Date.now();
|
|
201
|
+
tokens = Math.min(capacity, tokens + (now - lastRefill) / intervalMs);
|
|
202
|
+
lastRefill = now;
|
|
203
|
+
if (tokens >= 1) {
|
|
204
|
+
tokens -= 1;
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
await new Promise((resolve) => {
|
|
208
|
+
setTimeout(resolve, Math.ceil((1 - tokens) * intervalMs));
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
function errorMessage(error) {
|
|
214
|
+
return error instanceof Error ? error.message : String(error);
|
|
215
|
+
}
|
|
@@ -5,8 +5,6 @@ import { markdownDirBackend } from "./backends/markdown-dir.js";
|
|
|
5
5
|
import { yamlFileBackend } from "./backends/yaml-file.js";
|
|
6
6
|
import { validateMachine } from "./state-machine.js";
|
|
7
7
|
import { resolveStateMachine } from "./state.js";
|
|
8
|
-
const DEFAULT_LOCK_STALE_MS = 30_000;
|
|
9
|
-
const DEFAULT_LOCK_RETRIES = 20;
|
|
10
8
|
export const backendFactories = {
|
|
11
9
|
"markdown-dir": markdownDirBackend,
|
|
12
10
|
"yaml-file": yamlFileBackend
|
|
@@ -37,8 +35,6 @@ async function openFileBackend(options) {
|
|
|
37
35
|
},
|
|
38
36
|
singleList: markdownOptions?.singleList,
|
|
39
37
|
frontmatterMode: markdownOptions?.frontmatterMode ?? "strict",
|
|
40
|
-
lockStaleMs: options.lockStaleMs ?? DEFAULT_LOCK_STALE_MS,
|
|
41
|
-
lockRetries: options.lockRetries ?? DEFAULT_LOCK_RETRIES,
|
|
42
38
|
create: options.create ?? false,
|
|
43
39
|
fs: options.fs ?? createDefaultFs(),
|
|
44
40
|
stateMachine
|
|
@@ -51,6 +47,9 @@ async function openGhIssuesBackend(options) {
|
|
|
51
47
|
return ghIssuesBackend({
|
|
52
48
|
repo: options.repo,
|
|
53
49
|
project: options.project,
|
|
50
|
+
filter: options.filter,
|
|
51
|
+
state: options.state,
|
|
52
|
+
stateMachine: options.stateMachine,
|
|
54
53
|
defaults: {
|
|
55
54
|
metadata: { ...(options.defaults?.metadata ?? {}) }
|
|
56
55
|
},
|