@bumpyclock/pi-tasque 0.1.0
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/LICENSE +21 -0
- package/NOTICE.md +7 -0
- package/README.md +315 -0
- package/package.json +39 -0
- package/src/bridge/bridge-tool.ts +185 -0
- package/src/bridge/import-tsq.ts +502 -0
- package/src/bridge/link-store.ts +97 -0
- package/src/bridge/promote-todo.ts +331 -0
- package/src/bridge/types.ts +156 -0
- package/src/durable-tasks/cache.ts +167 -0
- package/src/durable-tasks/mutation-queue.ts +30 -0
- package/src/durable-tasks/runner.ts +234 -0
- package/src/durable-tasks/status.ts +184 -0
- package/src/durable-tasks/tools-change.ts +600 -0
- package/src/durable-tasks/tools-claim.ts +426 -0
- package/src/durable-tasks/tools-query.ts +496 -0
- package/src/durable-tasks/types.ts +193 -0
- package/src/index.ts +21 -0
- package/src/session-todos/state/invariants.ts +17 -0
- package/src/session-todos/state/replay.ts +272 -0
- package/src/session-todos/state/selectors.ts +140 -0
- package/src/session-todos/state/state-reducer.ts +292 -0
- package/src/session-todos/state/state.ts +69 -0
- package/src/session-todos/state/store.ts +37 -0
- package/src/session-todos/state/task-graph.ts +58 -0
- package/src/session-todos/todo-overlay.ts +223 -0
- package/src/session-todos/todo.ts +239 -0
- package/src/session-todos/tool/response-envelope.ts +143 -0
- package/src/session-todos/tool/types.ts +149 -0
- package/src/session-todos/view/format.ts +264 -0
- package/src/shared/tool-result.ts +81 -0
- package/src/shared/truncation.ts +150 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { TaskStatus } from "../tool/types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Todo status transition table. Same-status updates are idempotent; `deleted`
|
|
5
|
+
* is terminal and `completed` may only move to `deleted`.
|
|
6
|
+
*/
|
|
7
|
+
export const VALID_TRANSITIONS: Record<TaskStatus, ReadonlySet<TaskStatus>> = {
|
|
8
|
+
pending: new Set(["in_progress", "completed", "deleted"]),
|
|
9
|
+
in_progress: new Set(["pending", "completed", "deleted"]),
|
|
10
|
+
completed: new Set(["deleted"]),
|
|
11
|
+
deleted: new Set(),
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function isTransitionValid(from: TaskStatus, to: TaskStatus): boolean {
|
|
15
|
+
if (from === to) return true;
|
|
16
|
+
return VALID_TRANSITIONS[from].has(to);
|
|
17
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TOOL_NAME,
|
|
3
|
+
type Task,
|
|
4
|
+
type TaskDetails,
|
|
5
|
+
type TaskStatus,
|
|
6
|
+
} from "../tool/types.js";
|
|
7
|
+
import {
|
|
8
|
+
cloneTask,
|
|
9
|
+
cloneTaskState,
|
|
10
|
+
EMPTY_STATE,
|
|
11
|
+
type TaskState,
|
|
12
|
+
} from "./state.js";
|
|
13
|
+
|
|
14
|
+
interface BranchContext {
|
|
15
|
+
sessionManager: {
|
|
16
|
+
getBranch(): Iterable<unknown>;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface BranchMessageEntry {
|
|
21
|
+
type?: string;
|
|
22
|
+
message?: {
|
|
23
|
+
role?: string;
|
|
24
|
+
toolName?: string;
|
|
25
|
+
details?: unknown;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const VALID_STATUSES = new Set<TaskStatus>([
|
|
30
|
+
"pending",
|
|
31
|
+
"in_progress",
|
|
32
|
+
"completed",
|
|
33
|
+
"deleted",
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
const TASK_BRIDGE_REPLAY_TOOL_NAME = "task_bridge";
|
|
37
|
+
const TSQ_CLAIM_REPLAY_TOOL_NAME = "tsq_claim";
|
|
38
|
+
const TASK_BRIDGE_MUTATION_ACTIONS = new Set(["promote_todo", "import_tsq"]);
|
|
39
|
+
|
|
40
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
41
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
42
|
+
const prototype = Object.getPrototypeOf(value);
|
|
43
|
+
return prototype === Object.prototype || prototype === null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isSafePositiveInteger(value: unknown): value is number {
|
|
47
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 1;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isOptionalString(value: unknown): boolean {
|
|
51
|
+
return value === undefined || typeof value === "string";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isTask(value: unknown): value is Task {
|
|
55
|
+
if (!isPlainObject(value)) return false;
|
|
56
|
+
return (
|
|
57
|
+
isSafePositiveInteger(value.id) &&
|
|
58
|
+
typeof value.subject === "string" &&
|
|
59
|
+
value.subject.trim().length > 0 &&
|
|
60
|
+
typeof value.status === "string" &&
|
|
61
|
+
VALID_STATUSES.has(value.status as TaskStatus) &&
|
|
62
|
+
isOptionalString(value.description) &&
|
|
63
|
+
isOptionalString(value.activeForm) &&
|
|
64
|
+
isOptionalString(value.owner) &&
|
|
65
|
+
(value.blockedBy === undefined ||
|
|
66
|
+
(Array.isArray(value.blockedBy) &&
|
|
67
|
+
value.blockedBy.every(isSafePositiveInteger))) &&
|
|
68
|
+
(value.metadata === undefined || isPlainObject(value.metadata))
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function hasBlockedByCycle(tasks: readonly Task[]): boolean {
|
|
73
|
+
const edges = new Map<number, readonly number[]>();
|
|
74
|
+
for (const task of tasks) edges.set(task.id, task.blockedBy ?? []);
|
|
75
|
+
|
|
76
|
+
const visiting = new Set<number>();
|
|
77
|
+
const visited = new Set<number>();
|
|
78
|
+
|
|
79
|
+
const visit = (taskId: number): boolean => {
|
|
80
|
+
if (visiting.has(taskId)) return true;
|
|
81
|
+
if (visited.has(taskId)) return false;
|
|
82
|
+
|
|
83
|
+
visiting.add(taskId);
|
|
84
|
+
for (const blockerId of edges.get(taskId) ?? []) {
|
|
85
|
+
if (visit(blockerId)) return true;
|
|
86
|
+
}
|
|
87
|
+
visiting.delete(taskId);
|
|
88
|
+
visited.add(taskId);
|
|
89
|
+
return false;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
for (const taskId of edges.keys()) {
|
|
93
|
+
if (visit(taskId)) return true;
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function hasValidTaskSnapshot(tasks: readonly Task[], nextId: number): boolean {
|
|
99
|
+
const tasksById = new Map<number, Task>();
|
|
100
|
+
let maxTaskId = 0;
|
|
101
|
+
|
|
102
|
+
for (const task of tasks) {
|
|
103
|
+
if (tasksById.has(task.id)) return false;
|
|
104
|
+
tasksById.set(task.id, task);
|
|
105
|
+
maxTaskId = Math.max(maxTaskId, task.id);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (tasks.length > 0 && nextId <= maxTaskId) return false;
|
|
109
|
+
|
|
110
|
+
for (const task of tasks) {
|
|
111
|
+
for (const blockerId of task.blockedBy ?? []) {
|
|
112
|
+
if (blockerId === task.id) return false;
|
|
113
|
+
const blocker = tasksById.get(blockerId);
|
|
114
|
+
if (!blocker || blocker.status === "deleted") return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return !hasBlockedByCycle(tasks);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Defensive snapshot guard. Replay only needs the persisted task list and next
|
|
123
|
+
* id; older compatible todo results may include extra fields.
|
|
124
|
+
*/
|
|
125
|
+
export function isTaskDetails(value: unknown): value is TaskDetails {
|
|
126
|
+
if (!isPlainObject(value)) return false;
|
|
127
|
+
const tasks = value.tasks;
|
|
128
|
+
return (
|
|
129
|
+
Array.isArray(tasks) &&
|
|
130
|
+
tasks.every(isTask) &&
|
|
131
|
+
isSafePositiveInteger(value.nextId) &&
|
|
132
|
+
hasValidTaskSnapshot(tasks, value.nextId)
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function emptyState(): TaskState {
|
|
137
|
+
return cloneTaskState(EMPTY_STATE);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
interface BridgeLinkReplayDetails {
|
|
141
|
+
readonly todoId: number;
|
|
142
|
+
readonly tsqId: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function getReplayableBridgeLink(
|
|
146
|
+
details: unknown,
|
|
147
|
+
): BridgeLinkReplayDetails | undefined {
|
|
148
|
+
if (!isPlainObject(details) || details.ok !== true) return undefined;
|
|
149
|
+
const data = details.data;
|
|
150
|
+
if (!isPlainObject(data) || data.action !== "link") return undefined;
|
|
151
|
+
const link = data.link;
|
|
152
|
+
if (!isPlainObject(link)) return undefined;
|
|
153
|
+
if (!isSafePositiveInteger(link.todoId)) return undefined;
|
|
154
|
+
if (typeof link.tsqId !== "string") return undefined;
|
|
155
|
+
|
|
156
|
+
const tsqId = link.tsqId.trim();
|
|
157
|
+
if (tsqId.length === 0) return undefined;
|
|
158
|
+
return { todoId: link.todoId, tsqId };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function applyReplayableBridgeLink(
|
|
162
|
+
state: TaskState,
|
|
163
|
+
link: BridgeLinkReplayDetails,
|
|
164
|
+
): TaskState {
|
|
165
|
+
const taskIndex = state.tasks.findIndex((task) => task.id === link.todoId);
|
|
166
|
+
const task = state.tasks[taskIndex];
|
|
167
|
+
if (task === undefined || task.status === "deleted") return state;
|
|
168
|
+
|
|
169
|
+
const metadata = { ...(task.metadata ?? {}), tsqId: link.tsqId };
|
|
170
|
+
const tasks = [...state.tasks];
|
|
171
|
+
tasks[taskIndex] = { ...task, metadata };
|
|
172
|
+
return { tasks, nextId: state.nextId };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function getReplayableBridgeTodoSnapshot(
|
|
176
|
+
details: unknown,
|
|
177
|
+
): TaskState | undefined {
|
|
178
|
+
if (!isPlainObject(details) || details.ok !== true) return undefined;
|
|
179
|
+
const data = details.data;
|
|
180
|
+
if (!isPlainObject(data)) return undefined;
|
|
181
|
+
if (
|
|
182
|
+
typeof data.action !== "string" ||
|
|
183
|
+
!TASK_BRIDGE_MUTATION_ACTIONS.has(data.action)
|
|
184
|
+
) {
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const snapshot = data.todoSnapshot;
|
|
189
|
+
if (!isTaskDetails(snapshot)) return undefined;
|
|
190
|
+
return cloneTaskState(snapshot);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function getReplayableClaimTodo(details: unknown): Task | undefined {
|
|
194
|
+
if (!isPlainObject(details) || details.ok !== true) return undefined;
|
|
195
|
+
const data = details.data;
|
|
196
|
+
if (!isPlainObject(data) || data.createTodo !== true) return undefined;
|
|
197
|
+
const todo = data.todo;
|
|
198
|
+
if (!isTask(todo)) return undefined;
|
|
199
|
+
|
|
200
|
+
const tsqId = getClaimTodoTsqId(data, todo);
|
|
201
|
+
if (tsqId === undefined) return undefined;
|
|
202
|
+
return cloneTask({ ...todo, metadata: { ...(todo.metadata ?? {}), tsqId } });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function getClaimTodoTsqId(
|
|
206
|
+
data: Record<string, unknown>,
|
|
207
|
+
todo: Task,
|
|
208
|
+
): string | undefined {
|
|
209
|
+
const id = data.id;
|
|
210
|
+
if (typeof id === "string" && id.trim().length > 0) return id.trim();
|
|
211
|
+
const metadataId = todo.metadata?.tsqId;
|
|
212
|
+
if (typeof metadataId === "string" && metadataId.trim().length > 0) {
|
|
213
|
+
return metadataId.trim();
|
|
214
|
+
}
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function applyReplayableClaimTodo(state: TaskState, todo: Task): TaskState {
|
|
219
|
+
if (state.tasks.some((task) => task.id === todo.id)) return state;
|
|
220
|
+
|
|
221
|
+
const tasks = [...state.tasks, todo];
|
|
222
|
+
const nextId = Math.max(state.nextId, todo.id + 1);
|
|
223
|
+
if (!hasValidTaskSnapshot(tasks, nextId)) return state;
|
|
224
|
+
return { tasks, nextId };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Rebuild todo state from the current session branch. The latest compatible
|
|
229
|
+
* `todo` tool result wins; malformed snapshots are skipped. Successful
|
|
230
|
+
* state-mutating `task_bridge` results replay their todo snapshot, while
|
|
231
|
+
* successful `task_bridge link` results and successful `tsq_claim` createTodo
|
|
232
|
+
* results are replayed onto the current todo snapshot so bridge metadata,
|
|
233
|
+
* imports/promotions, and claim-created todos survive reload/branch replay.
|
|
234
|
+
*/
|
|
235
|
+
export function replayFromBranch(ctx: BranchContext): TaskState {
|
|
236
|
+
let result = emptyState();
|
|
237
|
+
|
|
238
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
239
|
+
const branchEntry = entry as BranchMessageEntry;
|
|
240
|
+
if (branchEntry.type !== "message") continue;
|
|
241
|
+
|
|
242
|
+
const message = branchEntry.message;
|
|
243
|
+
if (!message || message.role !== "toolResult") continue;
|
|
244
|
+
|
|
245
|
+
if (message.toolName === TOOL_NAME) {
|
|
246
|
+
if (!isTaskDetails(message.details)) continue;
|
|
247
|
+
result = cloneTaskState(message.details);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (message.toolName === TASK_BRIDGE_REPLAY_TOOL_NAME) {
|
|
252
|
+
const snapshot = getReplayableBridgeTodoSnapshot(message.details);
|
|
253
|
+
if (snapshot !== undefined) {
|
|
254
|
+
result = snapshot;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const link = getReplayableBridgeLink(message.details);
|
|
259
|
+
if (link === undefined) continue;
|
|
260
|
+
result = applyReplayableBridgeLink(result, link);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (message.toolName === TSQ_CLAIM_REPLAY_TOOL_NAME) {
|
|
265
|
+
const todo = getReplayableClaimTodo(message.details);
|
|
266
|
+
if (todo === undefined) continue;
|
|
267
|
+
result = applyReplayableClaimTodo(result, todo);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return result;
|
|
272
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { Task, TaskStatus } from "../tool/types.js";
|
|
2
|
+
import type { TaskState } from "./state.js";
|
|
3
|
+
|
|
4
|
+
/** Tasks excluding deleted tombstones — canonical visible todo list. */
|
|
5
|
+
export function selectVisibleTasks(state: TaskState): readonly Task[] {
|
|
6
|
+
return state.tasks.filter((task) => task.status !== "deleted");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface TasksByStatus {
|
|
10
|
+
pending: readonly Task[];
|
|
11
|
+
inProgress: readonly Task[];
|
|
12
|
+
completed: readonly Task[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Visible tasks grouped by user-facing status buckets. */
|
|
16
|
+
export function selectTasksByStatus(state: TaskState): TasksByStatus {
|
|
17
|
+
const visible = selectVisibleTasks(state);
|
|
18
|
+
return {
|
|
19
|
+
pending: visible.filter((task) => task.status === "pending"),
|
|
20
|
+
inProgress: visible.filter((task) => task.status === "in_progress"),
|
|
21
|
+
completed: visible.filter((task) => task.status === "completed"),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface TodoCounts {
|
|
26
|
+
total: number;
|
|
27
|
+
pending: number;
|
|
28
|
+
inProgress: number;
|
|
29
|
+
completed: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Counts used by the overlay heading and /todos command summary. */
|
|
33
|
+
export function selectTodoCounts(state: TaskState): TodoCounts {
|
|
34
|
+
const groups = selectTasksByStatus(state);
|
|
35
|
+
return {
|
|
36
|
+
total:
|
|
37
|
+
groups.pending.length +
|
|
38
|
+
groups.inProgress.length +
|
|
39
|
+
groups.completed.length,
|
|
40
|
+
pending: groups.pending.length,
|
|
41
|
+
inProgress: groups.inProgress.length,
|
|
42
|
+
completed: groups.completed.length,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Show row ids only when dependency suffixes need an id anchor. */
|
|
47
|
+
export function selectShowTaskIds(state: TaskState): boolean {
|
|
48
|
+
return selectVisibleTasks(state).some(
|
|
49
|
+
(task) => (task.blockedBy?.length ?? 0) > 0,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Resolve a task subject by id for tool-call rendering. */
|
|
54
|
+
export function selectTaskSubjectById(
|
|
55
|
+
state: TaskState,
|
|
56
|
+
id: number,
|
|
57
|
+
): string | undefined {
|
|
58
|
+
return state.tasks.find((task) => task.id === id)?.subject;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface OverlayLayout {
|
|
62
|
+
visible: readonly Task[];
|
|
63
|
+
hiddenCompleted: number;
|
|
64
|
+
truncatedTail: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Decide which rows fit in the overlay body.
|
|
69
|
+
*
|
|
70
|
+
* When overflowing, one body slot is reserved for a summary row. Completed rows
|
|
71
|
+
* are hidden first; if active rows still overflow, the active tail is truncated.
|
|
72
|
+
*/
|
|
73
|
+
export function selectOverlayLayout(
|
|
74
|
+
state: TaskState,
|
|
75
|
+
budget: number,
|
|
76
|
+
): OverlayLayout {
|
|
77
|
+
const all = selectVisibleTasks(state);
|
|
78
|
+
const normalizedBudget = Number.isFinite(budget)
|
|
79
|
+
? Math.max(0, Math.floor(budget))
|
|
80
|
+
: 0;
|
|
81
|
+
const nonCompleted = all.filter((task) => task.status !== "completed");
|
|
82
|
+
const totalCompleted = all.length - nonCompleted.length;
|
|
83
|
+
|
|
84
|
+
if (all.length <= normalizedBudget) {
|
|
85
|
+
return { visible: all, hiddenCompleted: 0, truncatedTail: 0 };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (normalizedBudget <= 0) {
|
|
89
|
+
return {
|
|
90
|
+
visible: [],
|
|
91
|
+
hiddenCompleted: totalCompleted,
|
|
92
|
+
truncatedTail: nonCompleted.length,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const rowBudget = normalizedBudget - 1;
|
|
97
|
+
if (rowBudget <= 0) {
|
|
98
|
+
return {
|
|
99
|
+
visible: [],
|
|
100
|
+
hiddenCompleted: totalCompleted,
|
|
101
|
+
truncatedTail: nonCompleted.length,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (nonCompleted.length <= rowBudget) {
|
|
106
|
+
const kept = new Set<Task>(nonCompleted);
|
|
107
|
+
for (const task of all) {
|
|
108
|
+
if (kept.size >= rowBudget) break;
|
|
109
|
+
if (task.status === "completed") kept.add(task);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const visible = all.filter((task) => kept.has(task));
|
|
113
|
+
const shownCompleted = visible.filter(
|
|
114
|
+
(task) => task.status === "completed",
|
|
115
|
+
).length;
|
|
116
|
+
return {
|
|
117
|
+
visible,
|
|
118
|
+
hiddenCompleted: totalCompleted - shownCompleted,
|
|
119
|
+
truncatedTail: 0,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
visible: nonCompleted.slice(0, rowBudget),
|
|
125
|
+
hiddenCompleted: totalCompleted,
|
|
126
|
+
truncatedTail: nonCompleted.length - rowBudget,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Whether any visible task is still actionable. */
|
|
131
|
+
export function selectHasActive(state: TaskState): boolean {
|
|
132
|
+
return selectVisibleTasks(state).some((task) =>
|
|
133
|
+
ACTIVE_STATUSES.has(task.status),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export const ACTIVE_STATUSES: ReadonlySet<TaskStatus> = new Set([
|
|
138
|
+
"pending",
|
|
139
|
+
"in_progress",
|
|
140
|
+
]);
|