@passioncode-ai/passioncode 0.2.42 → 0.2.43
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/CHANGELOG.md +9 -0
- package/family.json +1 -1
- package/package.json +1 -1
- package/payload/.claude-plugin/marketplace.json +2 -2
- package/payload/manifest.json +8 -8
- package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/payload/plugins/fabric-agent-adapter/hooks/hooks.json +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +7 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +82 -11
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +2 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +33 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +146 -7
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +34 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +149 -45
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +181 -5
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +13 -1
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +15 -10
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +4 -1
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +38 -9
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +11 -0
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +12 -3
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +11 -1
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +358 -14
- package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
|
@@ -26,6 +26,9 @@ const TERMINAL = new Set(['completed', 'failed_known', 'cancelled', 'expired']);
|
|
|
26
26
|
// participant reply (DEC-0098, DEC-0102; not terminal, the requester may still cancel it, and nobody claims it).
|
|
27
27
|
const NOT_OURS = new Set(['queued', 'claimed', 'answered']);
|
|
28
28
|
|
|
29
|
+
// The answer may have been stored: send it again with the same key (BoardError).
|
|
30
|
+
const LOST = ['unreachable', 'session-ended'];
|
|
31
|
+
|
|
29
32
|
/** A lost call is sent again this long before the lease it keeps would lapse (FAA-77, finding 38). */
|
|
30
33
|
export const LEASE_MARGIN_SECONDS = 10;
|
|
31
34
|
|
|
@@ -87,6 +90,7 @@ export class Responder {
|
|
|
87
90
|
/** One fenced call at a time, and the entry edits beside them (FAA-77, finding 36). */
|
|
88
91
|
this.fenceQueue = Promise.resolve();
|
|
89
92
|
this.toResume = []; // transferred requests waiting until nothing runs (finding 39)
|
|
93
|
+
this.suspect = new Set(); // capabilities a fenced call was refused on: com.status says whether they moved (FAA-155)
|
|
90
94
|
}
|
|
91
95
|
|
|
92
96
|
/** Runs fn after every fenced call queued before it (finding 36): an await inside a call never lets another in. */
|
|
@@ -167,13 +171,14 @@ export class Responder {
|
|
|
167
171
|
const args = { idempotency: this.idem(this.key('enroll')), capability };
|
|
168
172
|
let receipt;
|
|
169
173
|
try {
|
|
170
|
-
receipt = this.receipt('com.enroll', await this.
|
|
174
|
+
receipt = this.receipt('com.enroll', await this.ask(capability, 'com.enroll', args));
|
|
171
175
|
} catch (e) {
|
|
172
176
|
if (e.code === 'held') {
|
|
173
177
|
this.pause(capability);
|
|
174
178
|
return;
|
|
175
179
|
}
|
|
176
180
|
if (e.code === 'fenced') {
|
|
181
|
+
if (await this.gone(capability)) return;
|
|
177
182
|
this.slots[capability] = { state: 'contested', renewedAt: this.now() };
|
|
178
183
|
this.emit('board.slot.contested', 'warn', `Another responder holds the ${capability} slot on this Project; this service waits for it to lapse and never takes it over by itself.`, { notify: true, episode: capability, capability });
|
|
179
184
|
return;
|
|
@@ -181,6 +186,7 @@ export class Responder {
|
|
|
181
186
|
throw e;
|
|
182
187
|
}
|
|
183
188
|
this.slots[capability] = { state: 'held', generation: receipt.slot.generation, renewedAt: this.now() };
|
|
189
|
+
this.heldAt(capability, receipt.slot.generation);
|
|
184
190
|
delete this.said['board.slot.contested'];
|
|
185
191
|
this.lifted(capability);
|
|
186
192
|
}
|
|
@@ -205,6 +211,118 @@ export class Responder {
|
|
|
205
211
|
this.emit('board.slot.released', 'info', `The operator's hold on ${capability} was lifted; the service takes new work again.`, { capability });
|
|
206
212
|
}
|
|
207
213
|
|
|
214
|
+
// -- a slot moved to another session (FAA-155, DEC-0140 PR-2) --
|
|
215
|
+
/** The generation this responder holds the slot at, kept beside its checkpoint; a failed write is retried at the next receipt. */
|
|
216
|
+
heldAt(capability, generation) {
|
|
217
|
+
this.note(capability, { project: this.project, generation });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The slot record, best effort: one that cannot be written now is written at the next receipt. */
|
|
221
|
+
note(capability, record) {
|
|
222
|
+
try {
|
|
223
|
+
this.checkpoint.slot(capability, record);
|
|
224
|
+
} catch {
|
|
225
|
+
// the request steps fail closed on their own
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* An enroll or replace is recorded with its key before it is sent, so a lost answer is never read as a move; a refusal
|
|
231
|
+
* drops the record, a receipt replaces it (heldAt), a record that cannot be written is skipped.
|
|
232
|
+
*/
|
|
233
|
+
async ask(capability, op, args) {
|
|
234
|
+
let held = this.checkpoint.slots[capability] ?? {};
|
|
235
|
+
if (held.project !== this.project) return this.call(op, args); // no record: nothing a lost answer could be mistaken for
|
|
236
|
+
const slot = this.slots[capability] ?? {};
|
|
237
|
+
if (op === 'com.enroll' && slot.state === 'held' && slot.generation === held.generation
|
|
238
|
+
&& this.now() - (slot.renewedAt ?? 0) < LEASE_SECONDS - LEASE_MARGIN_SECONDS && !('asking' in held)) {
|
|
239
|
+
return this.call(op, args); // a live holder's renew cannot change its generation: nothing to record
|
|
240
|
+
}
|
|
241
|
+
if (isObj(held.asking)) {
|
|
242
|
+
// An earlier lost ask is settled first; it may show the slot moved.
|
|
243
|
+
if (await this.gone(capability)) throw new BoardError('fenced', `The ${capability} slot moved to another session.`, false, op);
|
|
244
|
+
held = this.checkpoint.slots[capability] ?? {};
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
this.checkpoint.slot(capability, { ...held, asking: { op, args } });
|
|
248
|
+
} catch {
|
|
249
|
+
return this.call(op, args);
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
return await this.call(op, args);
|
|
253
|
+
} catch (e) {
|
|
254
|
+
if (!LOST.includes(e.code)) this.note(capability, held);
|
|
255
|
+
throw e;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Asks a lost enroll or replace again under its key. `repeated: true`: it landed, the generation is ours. Else it ran
|
|
261
|
+
* only now: if the slot had moved past the record (`before`), the record stays and the move is read (the slot just
|
|
262
|
+
* taken lapses unused). A refusal or an odd receipt drops the record; a lost answer keeps it.
|
|
263
|
+
*/
|
|
264
|
+
async replay(capability, held, before) {
|
|
265
|
+
const { asking, ...rest } = held;
|
|
266
|
+
let record = rest;
|
|
267
|
+
if (isObj(asking) && ['com.enroll', 'com.responder_replace'].includes(asking.op)) {
|
|
268
|
+
try {
|
|
269
|
+
const answer = this.receipt(asking.op, await this.call(asking.op, asking.args));
|
|
270
|
+
const generation = answer.slot?.generation;
|
|
271
|
+
const moved = isInt(before, 1) && isInt(rest.generation, 1) && before > rest.generation;
|
|
272
|
+
if (isInt(generation, 1) && (answer.repeated === true || !moved)) record = { project: this.project, generation };
|
|
273
|
+
} catch (e) {
|
|
274
|
+
if (LOST.includes(e.code)) throw e;
|
|
275
|
+
if (e.code === 'idempotency_window_expired') record = {}; // a retired epoch: unknown, so a first start, no move
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
this.note(capability, Object.keys(record).length ? record : null);
|
|
279
|
+
return record;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* True when com.status shows the slot's generation past the one recorded held: the operator or another session moved
|
|
284
|
+
* it. The slot is left `moved`. No record, another Project's, an odd value or no later generation: false.
|
|
285
|
+
*/
|
|
286
|
+
async gone(capability, status = null) {
|
|
287
|
+
let held = this.checkpoint.slots[capability] ?? {};
|
|
288
|
+
if (held.project !== this.project || (!isInt(held.generation, 1) && !('asking' in held))) return false;
|
|
289
|
+
let now = ((status ?? await this.status()).responders ?? []).find((r) => isObj(r) && r.capability === capability)?.generation;
|
|
290
|
+
if ('asking' in held) held = await this.replay(capability, held, now); // its own lost ask first
|
|
291
|
+
if (isInt(held.moved, 1) /* a recorded move stays until takeBack */ && !(isInt(now, 1) && now > (held.generation ?? now))) now = held.moved;
|
|
292
|
+
if (!isInt(now, 1) || !isInt(held.generation, 1) || now <= held.generation) return false;
|
|
293
|
+
if (this.current?.entry.capability === capability) {
|
|
294
|
+
this.current.ctx.stopped = true;
|
|
295
|
+
this.current = null;
|
|
296
|
+
}
|
|
297
|
+
for (const entry of Object.values(this.checkpoint.entries).filter((e) => e.capability === capability).map((e) => ({ ...e }))) {
|
|
298
|
+
if (entry.step === 'claimed') this.checkpoint.drop(entry.request); // the move returned the claim to the queue
|
|
299
|
+
else if (!['unknown', 'lapsed'].includes(entry.step)) {
|
|
300
|
+
// Begun: outcome_unknown, which the slot's holder reconciles. Accepted: held on the board, continued only by a
|
|
301
|
+
// transfer if the slot is this service's again; never a com.responder_replace on its own.
|
|
302
|
+
entry.step = entry.effect ? 'unknown' : 'lapsed';
|
|
303
|
+
this.checkpoint.put(entry);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
this.toResume = this.toResume.filter((r) => this.checkpoint.entries[r]?.capability !== capability);
|
|
307
|
+
this.slots[capability] = { state: 'moved', generation: now, renewedAt: this.now() };
|
|
308
|
+
if (!('moved' in held)) { // once, even across restarts
|
|
309
|
+
this.emit('board.slot.moved', 'warn', `The ${capability} slot on this Project was moved to another session. This service stopped answering it there, left its accepted work held (work already begun is the new holder's to settle) and does not take the slot back by itself. Its take-back action brings it back once the other session lets it go; without one, ask its maintainer.`, { notify: true, episode: capability, capability, generation: now, held: held.generation });
|
|
310
|
+
this.note(capability, { ...held, moved: now }); // said again at the next start if it cannot be written
|
|
311
|
+
}
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* The operator's act, never the kit's: forget that a capability's slot moved away. In the same board session the next
|
|
317
|
+
* tick enrolls once the slot is free; a reconnect with held work takes it back at once by a transfer (FAA-155).
|
|
318
|
+
*/
|
|
319
|
+
takeBack(capability) {
|
|
320
|
+
if (!this.capabilities.includes(capability)) throw new BoardError('invalid_arguments', `This responder does not answer ${capability}.`);
|
|
321
|
+
this.checkpoint.slot(capability, null);
|
|
322
|
+
if (this.slots[capability]?.state === 'moved') this.slots[capability] = { state: 'contested', renewedAt: 0 };
|
|
323
|
+
delete this.said['board.slot.moved'];
|
|
324
|
+
}
|
|
325
|
+
|
|
208
326
|
// -- restart: reconcile and transfer --
|
|
209
327
|
async readState(entry) {
|
|
210
328
|
const message = await this.call('com.get', { message: entry.message });
|
|
@@ -237,13 +355,18 @@ export class Responder {
|
|
|
237
355
|
const generations = Object.fromEntries(responders.map((r) => [r.capability, r.generation ?? 0]));
|
|
238
356
|
const onHold = new Set(responders.filter((r) => r.held === true).map((r) => r.capability)); // DEC-0079
|
|
239
357
|
for (const capability of capabilities) {
|
|
358
|
+
const was = this.checkpoint.slots[capability]?.generation;
|
|
359
|
+
if (await this.gone(capability, status)) continue; // moved away: never replaced at recovery (FAA-155)
|
|
240
360
|
if (onHold.has(capability)) {
|
|
241
361
|
for (const entry of transfer[capability] ?? []) {
|
|
242
362
|
entry.step = 'lapsed'; // taken back by a transfer once the hold is lifted
|
|
243
363
|
this.checkpoint.put(entry);
|
|
244
364
|
}
|
|
245
365
|
this.pause(capability);
|
|
246
|
-
} else if (transfer[capability]?.length)
|
|
366
|
+
} else if (transfer[capability]?.length) { // at the generation a replay just recorded, else com.status's
|
|
367
|
+
const mine = this.checkpoint.slots[capability]?.generation;
|
|
368
|
+
await this.replace(capability, transfer[capability], isInt(mine, 1) && mine !== was ? mine : generations[capability] ?? 0);
|
|
369
|
+
}
|
|
247
370
|
else await this.enroll(capability);
|
|
248
371
|
}
|
|
249
372
|
for (const entry of reconcile) {
|
|
@@ -260,8 +383,10 @@ export class Responder {
|
|
|
260
383
|
if (entries.length) args.transfer = { requests: entries.map((e) => e.request).slice(0, 64), checkpoint: { summary: 'Resumed by the same service after a restart; no effect had begun.' } };
|
|
261
384
|
let receipt;
|
|
262
385
|
try {
|
|
263
|
-
receipt = this.receipt('com.responder_replace', await this.
|
|
386
|
+
receipt = this.receipt('com.responder_replace', await this.ask(capability, 'com.responder_replace', args));
|
|
264
387
|
} catch (e) {
|
|
388
|
+
// An earlier lost ask showed the slot moved: said once as board.slot.moved, no outage.
|
|
389
|
+
if (e.code === 'fenced' && this.slots[capability]?.state === 'moved') return;
|
|
265
390
|
if (e.code !== 'held') throw e;
|
|
266
391
|
for (const entry of entries) {
|
|
267
392
|
entry.step = 'lapsed'; // nothing moved: taken back by a transfer once the hold is lifted
|
|
@@ -271,6 +396,7 @@ export class Responder {
|
|
|
271
396
|
return;
|
|
272
397
|
}
|
|
273
398
|
this.slots[capability] = { state: 'held', generation: receipt.slot.generation, renewedAt: this.now() };
|
|
399
|
+
this.heldAt(capability, receipt.slot.generation);
|
|
274
400
|
this.lifted(capability);
|
|
275
401
|
const moved = new Map((receipt.transferred ?? []).map((item) => [item.request, item]));
|
|
276
402
|
for (const entry of entries) {
|
|
@@ -418,6 +544,7 @@ export class Responder {
|
|
|
418
544
|
|
|
419
545
|
async fault(e) {
|
|
420
546
|
const code = e.code ?? 'unreachable';
|
|
547
|
+
const connecting = this.state === 'connecting' && !Object.values(this.slots).some((s) => s?.state === 'held');
|
|
421
548
|
let wait = this.backoff.next();
|
|
422
549
|
const left = this.leaseLeft() - LEASE_MARGIN_SECONDS;
|
|
423
550
|
const keep = code === 'unreachable' && left > 0; // finding 38: the lease holds, so the session and the work stay
|
|
@@ -437,7 +564,8 @@ export class Responder {
|
|
|
437
564
|
this.emit('board.blocked', 'error', `The responder stopped answering requests: ${e.message}`, { notify: true, episode: code, reason: code });
|
|
438
565
|
} else {
|
|
439
566
|
this.state = 'down';
|
|
440
|
-
|
|
567
|
+
// A refusal in recovery that left no slot held reconnects (seam 6); one that left some held serves them (seam 7).
|
|
568
|
+
if ((connecting || ['session-ended', 'unreachable', 'not-running', 'not-a-hub', 'hub-unavailable', 'protocol'].includes(code)) && !keep) await this.dropSession();
|
|
441
569
|
this.emit('board.down', 'warn', 'Fabric\'s board cannot be reached; the responder tries again with a growing pause.', { episode: 'down', reason: code });
|
|
442
570
|
}
|
|
443
571
|
return wait;
|
|
@@ -470,7 +598,12 @@ export class Responder {
|
|
|
470
598
|
else await this.renew(work);
|
|
471
599
|
}
|
|
472
600
|
const heldByWork = work && this.current ? work.entry.capability : null;
|
|
601
|
+
for (const capability of [...this.suspect]) { // kept until com.status answered, so it is asked before any enroll
|
|
602
|
+
await this.gone(capability);
|
|
603
|
+
this.suspect.delete(capability);
|
|
604
|
+
}
|
|
473
605
|
for (const [capability, slot] of Object.entries(this.slots)) {
|
|
606
|
+
if (slot.state === 'moved') continue; // never enrolled again on its own, even when the new holder's lease lapses (FAA-155)
|
|
474
607
|
if (slot.state === 'paused') {
|
|
475
608
|
// still held: a longer pause; lifted: the slot is ours again
|
|
476
609
|
if (capability !== heldByWork && this.now() >= (slot.retryAt ?? 0)) await this.enroll(capability);
|
|
@@ -521,6 +654,7 @@ export class Responder {
|
|
|
521
654
|
answer = this.receipt('com.claim', await this.call('com.claim', { idempotency: this.idem(this.claimKeys[capability]), capability }));
|
|
522
655
|
} catch (e) {
|
|
523
656
|
if (e.code === 'lease_expired') {
|
|
657
|
+
this.slots[capability].renewedAt = -Infinity; // lapsed: this enroll may move the generation, so it is recorded
|
|
524
658
|
await this.enroll(capability);
|
|
525
659
|
continue;
|
|
526
660
|
}
|
|
@@ -530,7 +664,7 @@ export class Responder {
|
|
|
530
664
|
continue;
|
|
531
665
|
}
|
|
532
666
|
if (e.code === 'fenced') {
|
|
533
|
-
this.slots[capability] = { state: 'contested', renewedAt: this.now() };
|
|
667
|
+
if (!(await this.gone(capability))) this.slots[capability] = { state: 'contested', renewedAt: this.now() };
|
|
534
668
|
continue;
|
|
535
669
|
}
|
|
536
670
|
if (e.code === 'held') {
|
|
@@ -648,6 +782,7 @@ export class Responder {
|
|
|
648
782
|
continue;
|
|
649
783
|
}
|
|
650
784
|
if (STOP_CODES.has(e.code) || ['revision_conflict', 'invalid_transition', 'digest_conflict'].includes(e.code)) {
|
|
785
|
+
if (e.code === 'fenced') this.suspect.add(entry.capability); // the next tick asks com.status whether the slot moved
|
|
651
786
|
this.lose(entry, e.code);
|
|
652
787
|
if (e.code === 'authority_changed') throw e;
|
|
653
788
|
return null;
|
|
@@ -663,10 +798,13 @@ export class Responder {
|
|
|
663
798
|
entry.step = 'unknown';
|
|
664
799
|
this.checkpoint.put(entry);
|
|
665
800
|
this.emit('board.reconcile.needed', 'warn', 'The attempt was lost after its effect began; the outcome stays unknown until this service observes what happened.', { notify: true, episode: entry.request, request: entry.request, capability: entry.capability, reason: code });
|
|
666
|
-
} else if (
|
|
801
|
+
} else if (!['claimed', 'accepting'].includes(entry.step) && (code === 'lease_expired' || (code === 'fenced' && entry.capability in this.checkpoint.slots))) {
|
|
802
|
+
// Accepted work whose lease lapsed, or fenced off a slot this responder held (moved, FAA-155): the board holds it
|
|
803
|
+
// (C4). It continues only by a checkpoint transfer to this responder while it holds the slot.
|
|
667
804
|
entry.step = 'lapsed';
|
|
668
805
|
this.checkpoint.put(entry);
|
|
669
|
-
this.emit('board.attempt.lost', 'warn', 'This service\'s lease on an accepted request lapsed; it stopped working on it and takes it back by a transfer.'
|
|
806
|
+
this.emit('board.attempt.lost', 'warn', code === 'lease_expired' ? 'This service\'s lease on an accepted request lapsed; it stopped working on it and takes it back by a transfer.'
|
|
807
|
+
: 'The board fenced an accepted request away from this service; it stopped working on it, and nothing had been done outside it.', { request: entry.request, capability: entry.capability, reason: code });
|
|
670
808
|
} else {
|
|
671
809
|
this.checkpoint.drop(entry.request);
|
|
672
810
|
this.emit('board.attempt.lost', 'warn', `The board took a request back from this service (${code}); it stopped working on it, and nothing had been done outside the service.`, { request: entry.request, capability: entry.capability, reason: code });
|
|
@@ -854,6 +992,7 @@ export class Responder {
|
|
|
854
992
|
if (this.session) {
|
|
855
993
|
await this.session.close();
|
|
856
994
|
this.session = null;
|
|
995
|
+
this.checkpoint.leave(this.project); // the slots were released: no record of them can read as a move
|
|
857
996
|
}
|
|
858
997
|
this.state = 'stopped';
|
|
859
998
|
}
|
|
@@ -97,11 +97,14 @@ class BoardSession:
|
|
|
97
97
|
class Checkpoint:
|
|
98
98
|
"""The responder's own journal: one JSON file (0600) holding every request it has claimed and not yet settled, each
|
|
99
99
|
with its fence and the idempotency key of the step in flight. Written atomically and fsynced BEFORE the step it
|
|
100
|
-
announces is sent; a write that fails raises `BoardError("checkpoint")` and the step is not taken.
|
|
100
|
+
announces is sent; a write that fails raises `BoardError("checkpoint")` and the step is not taken. Beside the
|
|
101
|
+
requests, `slots` keeps the responder's own record of each slot it held (`{project, generation, moved?}`, FAA-155);
|
|
102
|
+
a record that is not an object is ignored, as a file without one is."""
|
|
101
103
|
|
|
102
104
|
def __init__(self, path: Path):
|
|
103
105
|
self.path = Path(path)
|
|
104
106
|
self._lock = threading.Lock()
|
|
107
|
+
self.slots: Dict[str, Dict[str, Any]] = {}
|
|
105
108
|
self.entries: Dict[str, Dict[str, Any]] = self._load()
|
|
106
109
|
|
|
107
110
|
def _load(self) -> Dict[str, Dict[str, Any]]:
|
|
@@ -115,12 +118,16 @@ class Checkpoint:
|
|
|
115
118
|
entries = doc.get("requests") if isinstance(doc, dict) and doc.get("version") == 1 else None
|
|
116
119
|
if not isinstance(entries, dict):
|
|
117
120
|
raise BoardError("checkpoint", "The board checkpoint %s is not one this kit writes." % self.path)
|
|
121
|
+
slots = doc.get("slots")
|
|
122
|
+
self.slots = {k: v for k, v in slots.items() if isinstance(v, dict)} if isinstance(slots, dict) else {}
|
|
118
123
|
return entries
|
|
119
124
|
|
|
120
|
-
def _write(self, entries: Dict[str, Dict[str, Any]]) -> None:
|
|
125
|
+
def _write(self, entries: Dict[str, Dict[str, Any]], slots: Optional[Dict[str, Dict[str, Any]]] = None) -> None:
|
|
126
|
+
slots = self.slots if slots is None else slots
|
|
127
|
+
doc: Dict[str, Any] = {"version": 1, "requests": entries, **({"slots": slots} if slots else {})}
|
|
121
128
|
try:
|
|
122
129
|
_fs.ensure_private_dir(self.path.parent)
|
|
123
|
-
_fs.atomic_write(self.path, (json.dumps(
|
|
130
|
+
_fs.atomic_write(self.path, (json.dumps(doc, indent=1, sort_keys=True) + "\n").encode())
|
|
124
131
|
except OSError as exc:
|
|
125
132
|
raise BoardError("checkpoint", "The board checkpoint could not be written (%s); nothing was sent." % (exc.strerror or exc.__class__.__name__)) from None
|
|
126
133
|
|
|
@@ -140,6 +147,30 @@ class Checkpoint:
|
|
|
140
147
|
self._write(entries)
|
|
141
148
|
self.entries = entries
|
|
142
149
|
|
|
150
|
+
def slot(self, capability: str, record: Optional[Dict[str, Any]]) -> None:
|
|
151
|
+
"""Writes the record of a slot this responder held (None forgets it); nothing is written when it is unchanged."""
|
|
152
|
+
with self._lock:
|
|
153
|
+
slots = {k: v for k, v in self.slots.items() if k != capability}
|
|
154
|
+
if record is not None:
|
|
155
|
+
slots[capability] = json.loads(json.dumps(record))
|
|
156
|
+
if slots != self.slots:
|
|
157
|
+
self._write(self.entries, slots)
|
|
158
|
+
self.slots = slots
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def leave(self, project: str) -> None:
|
|
162
|
+
"""At a clean leave (the session's DELETE released the slots): forget the record of each slot this service held
|
|
163
|
+
for `project` with no request left in the checkpoint and not moved away. A later generation is then another
|
|
164
|
+
session taking a free slot, not a move, and a kit that keeps no record (rolled back to before 0.53.0) leaves
|
|
165
|
+
nothing behind that reads as one. A record that cannot be written stays."""
|
|
166
|
+
busy = {e.get("capability") for e in list(self.entries.values())}
|
|
167
|
+
for capability, record in list(self.slots.items()):
|
|
168
|
+
if isinstance(record, dict) and record.get("project") == project and "moved" not in record and capability not in busy:
|
|
169
|
+
try:
|
|
170
|
+
self.slot(capability, None)
|
|
171
|
+
except BoardError:
|
|
172
|
+
pass
|
|
173
|
+
|
|
143
174
|
|
|
144
175
|
class BoardRequest:
|
|
145
176
|
"""A claimed request as data: never an instruction. `body` is the requester's text; `sender` its Project id and
|