@nosana/node 1.1.20-rc → 1.1.21-rc
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/dist/package.json +2 -2
- package/dist/src/NodeManager/node/resource/volume/volumeManager.js +3 -2
- package/dist/src/NodeManager/node/task/operations/enforceOperationTimeout.d.ts +18 -0
- package/dist/src/NodeManager/node/task/operations/enforceOperationTimeout.js +36 -0
- package/dist/src/NodeManager/node/task/operations/runTaskManagerOperation.js +16 -0
- package/dist/src/NodeManager/provider/ContainerStateManager.js +14 -2
- package/dist/src/NodeManager/provider/Provider.js +2 -1
- package/dist/src/NodeManager/provider/containerOrchestration/docker/index.js +8 -7
- package/dist/src/NodeManager/provider/containerOrchestration/podman/index.js +2 -1
- package/dist/src/NodeManager/utils/liveAbortSignal.d.ts +16 -0
- package/dist/src/NodeManager/utils/liveAbortSignal.js +18 -0
- package/npm-shrinkwrap.json +6 -6
- package/package.json +3 -3
package/dist/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nosana/node",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.21-rc",
|
|
4
4
|
"description": "",
|
|
5
5
|
"bin": {
|
|
6
6
|
"nosana-node": "./dist/src/index.js"
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
"@coral-xyz/anchor": "^0.28.1-beta.1",
|
|
74
74
|
"@inquirer/prompts": "^4.3.0",
|
|
75
75
|
"@nosana/ipfs": "^2.2.6",
|
|
76
|
-
"@nosana/sdk": "^0.4.
|
|
76
|
+
"@nosana/sdk": "^0.4.82",
|
|
77
77
|
"@solana/web3.js": "^1.78.0",
|
|
78
78
|
"bn.js": "^5.2.1",
|
|
79
79
|
"chalk": "^5.3.0",
|
|
@@ -10,6 +10,7 @@ import { convertFromBytes } from '../../utils/convertFromBytes.js';
|
|
|
10
10
|
import { createHFArgs } from '../helpers/createHFArgs.js';
|
|
11
11
|
import { createS3Args } from '../helpers/createS3Args.js';
|
|
12
12
|
import { createOllamaArgs } from '../helpers/createOllamaArgs.js';
|
|
13
|
+
import { liveAbortSignal } from '../../../utils/liveAbortSignal.js';
|
|
13
14
|
export class VolumeManager {
|
|
14
15
|
constructor(containerOrchestration, repository) {
|
|
15
16
|
this.containerOrchestration = containerOrchestration;
|
|
@@ -105,7 +106,7 @@ export class VolumeManager {
|
|
|
105
106
|
stdout: true,
|
|
106
107
|
stderr: false,
|
|
107
108
|
follow: true,
|
|
108
|
-
abortSignal: controller?.signal,
|
|
109
|
+
abortSignal: liveAbortSignal(controller?.signal),
|
|
109
110
|
});
|
|
110
111
|
let start = false;
|
|
111
112
|
let formatSize = 'kb';
|
|
@@ -135,7 +136,7 @@ export class VolumeManager {
|
|
|
135
136
|
});
|
|
136
137
|
const { StatusCode } = await container.wait({
|
|
137
138
|
condition: 'not-running',
|
|
138
|
-
abortSignal: controller?.signal,
|
|
139
|
+
abortSignal: liveAbortSignal(controller?.signal),
|
|
139
140
|
});
|
|
140
141
|
// If download failed, remove volume
|
|
141
142
|
if (StatusCode !== 0) {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
import type { EventEmitter } from 'events';
|
|
3
|
+
/**
|
|
4
|
+
* Enforces a maximum running time for a single operation.
|
|
5
|
+
*
|
|
6
|
+
* The clock starts when the operation emits `start` (i.e. the container is
|
|
7
|
+
* actually running) — the same lifecycle point the rest of
|
|
8
|
+
* `runTaskManagerOperation` keys its timeout enforcement off. If the operation
|
|
9
|
+
* is still running `timeoutSeconds` later, `onTimeout` is invoked; callers use
|
|
10
|
+
* this to abort the op. The timer is cleared as soon as the operation settles
|
|
11
|
+
* (`exit`/`error`) or is torn down (`end`), so a normally completing op never
|
|
12
|
+
* triggers it.
|
|
13
|
+
*
|
|
14
|
+
* The unit is seconds, matching the on-chain `job.timeout` convention.
|
|
15
|
+
*
|
|
16
|
+
* A missing, zero, or negative timeout disables enforcement (no-op).
|
|
17
|
+
*/
|
|
18
|
+
export declare function enforceOperationTimeout(emitter: EventEmitter, timeoutSeconds: number | undefined, onTimeout: () => void): void;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enforces a maximum running time for a single operation.
|
|
3
|
+
*
|
|
4
|
+
* The clock starts when the operation emits `start` (i.e. the container is
|
|
5
|
+
* actually running) — the same lifecycle point the rest of
|
|
6
|
+
* `runTaskManagerOperation` keys its timeout enforcement off. If the operation
|
|
7
|
+
* is still running `timeoutSeconds` later, `onTimeout` is invoked; callers use
|
|
8
|
+
* this to abort the op. The timer is cleared as soon as the operation settles
|
|
9
|
+
* (`exit`/`error`) or is torn down (`end`), so a normally completing op never
|
|
10
|
+
* triggers it.
|
|
11
|
+
*
|
|
12
|
+
* The unit is seconds, matching the on-chain `job.timeout` convention.
|
|
13
|
+
*
|
|
14
|
+
* A missing, zero, or negative timeout disables enforcement (no-op).
|
|
15
|
+
*/
|
|
16
|
+
export function enforceOperationTimeout(emitter, timeoutSeconds, onTimeout) {
|
|
17
|
+
if (!timeoutSeconds || timeoutSeconds <= 0)
|
|
18
|
+
return;
|
|
19
|
+
let handle;
|
|
20
|
+
const clear = () => {
|
|
21
|
+
if (handle) {
|
|
22
|
+
clearTimeout(handle);
|
|
23
|
+
handle = undefined;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
emitter.on('start', () => {
|
|
27
|
+
clear();
|
|
28
|
+
handle = setTimeout(() => {
|
|
29
|
+
handle = undefined;
|
|
30
|
+
onTimeout();
|
|
31
|
+
}, timeoutSeconds * 1000);
|
|
32
|
+
});
|
|
33
|
+
emitter.on('exit', clear);
|
|
34
|
+
emitter.on('error', clear);
|
|
35
|
+
emitter.on('end', clear);
|
|
36
|
+
}
|
|
@@ -4,6 +4,7 @@ import { finalizeEnvOnOperation } from '../globalStore/finalizeEnv.js';
|
|
|
4
4
|
import { createResultsObject, extractResultFromLog, } from '../../utils/extractResultsFromLogs.js';
|
|
5
5
|
import { stanatizeArrays } from '../globalStore/stanatizeArrays.js';
|
|
6
6
|
import { logEmitter } from '../../../monitoring/proxy/loggingProxy.js';
|
|
7
|
+
import { enforceOperationTimeout } from './enforceOperationTimeout.js';
|
|
7
8
|
/**
|
|
8
9
|
* Executes a full lifecycle of a container-based operation using internal class state.
|
|
9
10
|
*
|
|
@@ -70,6 +71,21 @@ export async function runTaskManagerOperation(flow, op, dependent) {
|
|
|
70
71
|
};
|
|
71
72
|
this.mainAbortController.signal.addEventListener('abort', handleMainAbort);
|
|
72
73
|
this.abortControllerMap.set(op.id, abort);
|
|
74
|
+
/**
|
|
75
|
+
* Enforce the operation's maximum run time, if one was supplied on its
|
|
76
|
+
* execution. The clock starts when the op emits `start`; if it's still
|
|
77
|
+
* running when the timeout elapses, we abort it with EXPIRED — the same
|
|
78
|
+
* clean (non-failure) stop used for job-level expiry — and record the reason
|
|
79
|
+
* for diagnostics/auditing.
|
|
80
|
+
*/
|
|
81
|
+
const opTimeoutSeconds = op.execution?.timeout;
|
|
82
|
+
enforceOperationTimeout(emitter, opTimeoutSeconds, () => {
|
|
83
|
+
this.operationStatus.set(op.id, OperationProgressStatuses.STOPPING);
|
|
84
|
+
this.repository.setOpStateDiagnosticsReason(this.job, index, {
|
|
85
|
+
reason: `Operation killed after exceeding its timeout of ${opTimeoutSeconds}s`,
|
|
86
|
+
});
|
|
87
|
+
abort.abort(StopReasons.EXPIRED);
|
|
88
|
+
});
|
|
73
89
|
/**
|
|
74
90
|
* quit the operation if the main controller is already emmited
|
|
75
91
|
*/
|
|
@@ -2,6 +2,7 @@ import { Readable } from 'stream';
|
|
|
2
2
|
import { createInterface } from 'readline';
|
|
3
3
|
import { parseBuffer } from './utils/parseBuffer.js';
|
|
4
4
|
import { parseDockerStat } from './utils/parseDockerStat.js';
|
|
5
|
+
import { liveAbortSignal } from '../utils/liveAbortSignal.js';
|
|
5
6
|
function destroyStream(stream) {
|
|
6
7
|
if (!stream)
|
|
7
8
|
return;
|
|
@@ -32,9 +33,15 @@ export class ContainerStateManager {
|
|
|
32
33
|
await Promise.all([this.attachLogStream(), this.attachStatsStream()]);
|
|
33
34
|
if (!this.restartPolicy) {
|
|
34
35
|
this.container
|
|
35
|
-
.wait({ abortSignal: this.controller.signal })
|
|
36
|
+
.wait({ abortSignal: liveAbortSignal(this.controller.signal) })
|
|
36
37
|
.finally(() => {
|
|
37
38
|
this.state = 'exited';
|
|
39
|
+
})
|
|
40
|
+
.catch(() => {
|
|
41
|
+
// `wait` rejects with an AbortError when the op is aborted (timeout,
|
|
42
|
+
// stop, expiry) while the container is still running. The exit is
|
|
43
|
+
// already reflected via `state` above and observed by `waitForExit`,
|
|
44
|
+
// so swallow the rejection to avoid an unhandled promise rejection.
|
|
38
45
|
});
|
|
39
46
|
return;
|
|
40
47
|
}
|
|
@@ -66,6 +73,11 @@ export class ContainerStateManager {
|
|
|
66
73
|
}, 1000);
|
|
67
74
|
}
|
|
68
75
|
async attachLogStream() {
|
|
76
|
+
// A re-attach can be scheduled (on stream close) before an abort lands;
|
|
77
|
+
// bail if the op is already gone so we never open a stream against a
|
|
78
|
+
// container we're tearing down. Mirrors `attachStatsStream`.
|
|
79
|
+
if (this.controller.signal.aborted)
|
|
80
|
+
return;
|
|
69
81
|
try {
|
|
70
82
|
this.currentLogStream = await this.container.logs({
|
|
71
83
|
stdout: true,
|
|
@@ -73,7 +85,7 @@ export class ContainerStateManager {
|
|
|
73
85
|
follow: true,
|
|
74
86
|
timestamps: true,
|
|
75
87
|
since: this.lastLogTimestamp + 1,
|
|
76
|
-
abortSignal: this.controller.signal,
|
|
88
|
+
abortSignal: liveAbortSignal(this.controller.signal),
|
|
77
89
|
});
|
|
78
90
|
this.currentLogStream.on('data', (data) => {
|
|
79
91
|
const { log, type, timestamp } = parseBuffer(data);
|
|
@@ -5,6 +5,7 @@ import { ExposedPortHealthCheck } from './ExposedPortHealthCheck.js';
|
|
|
5
5
|
import { s3HelperImage } from '../node/resource/definition/index.js';
|
|
6
6
|
import { applyLoggingProxyToClass } from '../monitoring/proxy/loggingProxy.js';
|
|
7
7
|
import { promiseTimeoutWrapper } from '../utils/timeoutPromiseWrapper.js';
|
|
8
|
+
import { liveAbortSignal } from '../utils/liveAbortSignal.js';
|
|
8
9
|
import { generateProxies, generateUrlSecretObject, } from '../utils/expose-util.js';
|
|
9
10
|
import { ContainerStateManager } from './ContainerStateManager.js';
|
|
10
11
|
export class Provider {
|
|
@@ -260,7 +261,7 @@ export class Provider {
|
|
|
260
261
|
await stateManager.waitForExit();
|
|
261
262
|
}
|
|
262
263
|
const info = await promiseTimeoutWrapper(container.inspect({
|
|
263
|
-
abortSignal: controller.signal,
|
|
264
|
+
abortSignal: liveAbortSignal(controller.signal),
|
|
264
265
|
}), 360, controller);
|
|
265
266
|
emitter.emit('exit', { exitCode: info.State.ExitCode });
|
|
266
267
|
}
|
|
@@ -3,6 +3,7 @@ import { DockerExtended } from './dockerExtended/index.js';
|
|
|
3
3
|
import { createSeverObject } from './utils/createServerObject.js';
|
|
4
4
|
import { checkDeprecationDeadline } from './utils/deadline.js';
|
|
5
5
|
import { createDockerRunOptions } from './createDockerRunOptions.js';
|
|
6
|
+
import { liveAbortSignal } from '../../../utils/liveAbortSignal.js';
|
|
6
7
|
export class DockerContainerOrchestration {
|
|
7
8
|
constructor(server, gpu, teeRuntime) {
|
|
8
9
|
this.name = 'docker';
|
|
@@ -103,7 +104,7 @@ export class DockerContainerOrchestration {
|
|
|
103
104
|
if (await this.docker.hasImage(image)) {
|
|
104
105
|
await this.docker
|
|
105
106
|
.getImage(image)
|
|
106
|
-
.remove({ force: true, abortSignal: controller?.signal });
|
|
107
|
+
.remove({ force: true, abortSignal: liveAbortSignal(controller?.signal) });
|
|
107
108
|
}
|
|
108
109
|
}
|
|
109
110
|
async createNetwork(_name, controller) {
|
|
@@ -117,7 +118,7 @@ export class DockerContainerOrchestration {
|
|
|
117
118
|
Driver: 'default',
|
|
118
119
|
Config: [{ Subnet: '192.168.101.0/24', Gateway: '192.168.101.1' }],
|
|
119
120
|
},
|
|
120
|
-
abortSignal: controller?.signal,
|
|
121
|
+
abortSignal: liveAbortSignal(controller?.signal),
|
|
121
122
|
});
|
|
122
123
|
}
|
|
123
124
|
catch (error) {
|
|
@@ -167,7 +168,7 @@ export class DockerContainerOrchestration {
|
|
|
167
168
|
const volume = this.docker.getVolume(name);
|
|
168
169
|
try {
|
|
169
170
|
await volume.inspect(); // This will throw if the volume doesn't exist
|
|
170
|
-
await volume.remove({ force: true, abortSignal: controller?.signal });
|
|
171
|
+
await volume.remove({ force: true, abortSignal: liveAbortSignal(controller?.signal) });
|
|
171
172
|
}
|
|
172
173
|
catch (error) {
|
|
173
174
|
if (error.statusCode === 404) {
|
|
@@ -218,7 +219,7 @@ export class DockerContainerOrchestration {
|
|
|
218
219
|
}
|
|
219
220
|
const container = await this.docker.createContainer({
|
|
220
221
|
...args,
|
|
221
|
-
abortSignal: controller?.signal,
|
|
222
|
+
abortSignal: liveAbortSignal(controller?.signal),
|
|
222
223
|
});
|
|
223
224
|
await container.start();
|
|
224
225
|
if (controller) {
|
|
@@ -258,7 +259,7 @@ export class DockerContainerOrchestration {
|
|
|
258
259
|
try {
|
|
259
260
|
const info = await container.inspect();
|
|
260
261
|
if (info.State.Status !== 'exited')
|
|
261
|
-
await container.stop({ abortSignal: controller?.signal });
|
|
262
|
+
await container.stop({ abortSignal: liveAbortSignal(controller?.signal) });
|
|
262
263
|
}
|
|
263
264
|
catch { }
|
|
264
265
|
}
|
|
@@ -271,12 +272,12 @@ export class DockerContainerOrchestration {
|
|
|
271
272
|
const container = await this.docker.getContainer(id);
|
|
272
273
|
try {
|
|
273
274
|
// Need to catch in case container is already stopped
|
|
274
|
-
await container.stop({ abortSignal: controller?.signal }).catch(() => { });
|
|
275
|
+
await container.stop({ abortSignal: liveAbortSignal(controller?.signal) }).catch(() => { });
|
|
275
276
|
const info = await container.inspect();
|
|
276
277
|
await container.remove({
|
|
277
278
|
force: true,
|
|
278
279
|
v: true,
|
|
279
|
-
abortSignal: controller?.signal,
|
|
280
|
+
abortSignal: liveAbortSignal(controller?.signal),
|
|
280
281
|
});
|
|
281
282
|
return info;
|
|
282
283
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { DockerContainerOrchestration } from '../docker/index.js';
|
|
2
2
|
import { createPodmanRunOptions } from './utils/createPodmanRunOptions.js';
|
|
3
3
|
import { fetch, Agent } from 'undici';
|
|
4
|
+
import { liveAbortSignal } from '../../../utils/liveAbortSignal.js';
|
|
4
5
|
export class PodmanContainerOrchestration extends DockerContainerOrchestration {
|
|
5
6
|
constructor(server, gpu, _teeRuntime) {
|
|
6
7
|
super(server, gpu);
|
|
@@ -32,7 +33,7 @@ export class PodmanContainerOrchestration extends DockerContainerOrchestration {
|
|
|
32
33
|
Driver: 'bridge',
|
|
33
34
|
Config: [{ Subnet: '192.168.101.0/24', Gateway: '192.168.101.1' }],
|
|
34
35
|
},
|
|
35
|
-
abortSignal: controller?.signal,
|
|
36
|
+
abortSignal: liveAbortSignal(controller?.signal),
|
|
36
37
|
});
|
|
37
38
|
}
|
|
38
39
|
catch (error) {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns the signal only while it is still active, otherwise `undefined`.
|
|
3
|
+
*
|
|
4
|
+
* docker-modem crashes with an uncaught `AbortError` when handed an
|
|
5
|
+
* already-aborted `AbortSignal`: with a socket path the request is built in a
|
|
6
|
+
* microtask (`getSocketPath().then(...)`), where `addAbortSignal` synchronously
|
|
7
|
+
* destroys the freshly-created `ClientRequest` and emits `'error'` before any
|
|
8
|
+
* handler is attached — an unhandled `'error'` event that takes down the
|
|
9
|
+
* process.
|
|
10
|
+
*
|
|
11
|
+
* Teardown calls (inspect/stop/remove) legitimately run after the op's signal
|
|
12
|
+
* has aborted, so every Docker call must be guarded with this: an active signal
|
|
13
|
+
* is passed through (cancellation still works), while an already-aborted one is
|
|
14
|
+
* dropped so the call runs to completion instead of crashing.
|
|
15
|
+
*/
|
|
16
|
+
export declare function liveAbortSignal(signal: AbortSignal | undefined): AbortSignal | undefined;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns the signal only while it is still active, otherwise `undefined`.
|
|
3
|
+
*
|
|
4
|
+
* docker-modem crashes with an uncaught `AbortError` when handed an
|
|
5
|
+
* already-aborted `AbortSignal`: with a socket path the request is built in a
|
|
6
|
+
* microtask (`getSocketPath().then(...)`), where `addAbortSignal` synchronously
|
|
7
|
+
* destroys the freshly-created `ClientRequest` and emits `'error'` before any
|
|
8
|
+
* handler is attached — an unhandled `'error'` event that takes down the
|
|
9
|
+
* process.
|
|
10
|
+
*
|
|
11
|
+
* Teardown calls (inspect/stop/remove) legitimately run after the op's signal
|
|
12
|
+
* has aborted, so every Docker call must be guarded with this: an active signal
|
|
13
|
+
* is passed through (cancellation still works), while an already-aborted one is
|
|
14
|
+
* dropped so the call runs to completion instead of crashing.
|
|
15
|
+
*/
|
|
16
|
+
export function liveAbortSignal(signal) {
|
|
17
|
+
return signal?.aborted ? undefined : signal;
|
|
18
|
+
}
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nosana/node",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.21-rc",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@nosana/node",
|
|
9
|
-
"version": "1.1.
|
|
9
|
+
"version": "1.1.21-rc",
|
|
10
10
|
"license": "ISC",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@coral-xyz/anchor": "^0.28.1-beta.1",
|
|
13
13
|
"@inquirer/prompts": "^4.3.0",
|
|
14
14
|
"@nosana/ipfs": "^2.2.6",
|
|
15
|
-
"@nosana/sdk": "^0.4.
|
|
15
|
+
"@nosana/sdk": "^0.4.82",
|
|
16
16
|
"@solana/web3.js": "^1.78.0",
|
|
17
17
|
"bn.js": "^5.2.1",
|
|
18
18
|
"chalk": "^5.3.0",
|
|
@@ -1609,9 +1609,9 @@
|
|
|
1609
1609
|
}
|
|
1610
1610
|
},
|
|
1611
1611
|
"node_modules/@nosana/sdk": {
|
|
1612
|
-
"version": "0.4.
|
|
1613
|
-
"resolved": "https://registry.npmjs.org/@nosana/sdk/-/sdk-0.4.
|
|
1614
|
-
"integrity": "sha512-
|
|
1612
|
+
"version": "0.4.82",
|
|
1613
|
+
"resolved": "https://registry.npmjs.org/@nosana/sdk/-/sdk-0.4.82.tgz",
|
|
1614
|
+
"integrity": "sha512-s964bc25mLM/Jb5ctSttZ58lbB3SR3KcxECF/BKlOWiN8VUqUEY/GLG6NAzkQ97r23IhenXSewOhNWMbY405Ig==",
|
|
1615
1615
|
"license": "ISC",
|
|
1616
1616
|
"dependencies": {
|
|
1617
1617
|
"@coral-xyz/anchor": "^0.28.1-beta.1",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nosana/node",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.21-rc",
|
|
4
4
|
"description": "",
|
|
5
5
|
"bin": {
|
|
6
6
|
"nosana-node": "./dist/src/index.js"
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
"@coral-xyz/anchor": "^0.28.1-beta.1",
|
|
74
74
|
"@inquirer/prompts": "^4.3.0",
|
|
75
75
|
"@nosana/ipfs": "^2.2.6",
|
|
76
|
-
"@nosana/sdk": "^0.4.
|
|
76
|
+
"@nosana/sdk": "^0.4.82",
|
|
77
77
|
"@solana/web3.js": "^1.78.0",
|
|
78
78
|
"bn.js": "^5.2.1",
|
|
79
79
|
"chalk": "^5.3.0",
|
|
@@ -105,4 +105,4 @@
|
|
|
105
105
|
"util": "^0.12.5",
|
|
106
106
|
"yaml": "^2.8.0"
|
|
107
107
|
}
|
|
108
|
-
}
|
|
108
|
+
}
|