job-application-agent 3.1.1 → 3.1.2
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 +10 -2
- package/job-application-agent/SKILL.md +28 -19
- package/job-application-agent/references/ANALYTICS.md +4 -0
- package/job-application-agent/references/RUNS.md +2 -1
- package/job-application-agent/references/SCHEMAS.md +8 -3
- package/job-application-agent/references/SOURCES.json +156 -0
- package/job-application-agent/references/SOURCES.md +66 -0
- package/job-application-agent/scripts/job-application.mjs +208 -23
- package/job-application-agent/scripts/source-community-client.mjs +254 -0
- package/job-application-agent/scripts/source-community-schema.mjs +180 -0
- package/job-application-agent/tests/job-application.test.mjs +11 -9
- package/job-application-agent/tests/privacy-audit.test.mjs +16 -0
- package/job-application-agent/tests/source-community-client.test.mjs +245 -0
- package/job-application-agent/tests/source-community-schema.test.mjs +166 -0
- package/job-application-agent/tests/telemetry-client.test.mjs +1 -1
- package/job-application-agent/tests/workflow-state.test.mjs +394 -7
- package/package.json +7 -3
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
4
5
|
import { appendFile, chmod, mkdir, open, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
5
6
|
import { platform } from 'node:os';
|
|
6
7
|
import { basename, join, resolve } from 'node:path';
|
|
8
|
+
import { pathToFileURL } from 'node:url';
|
|
7
9
|
|
|
8
10
|
import { createSecretStore, migrateLegacyStateDir, resolveStateDir } from './secret-store.mjs';
|
|
11
|
+
import { SourceCommunityClient } from './source-community-client.mjs';
|
|
12
|
+
import { normalizeCommunitySource } from './source-community-schema.mjs';
|
|
9
13
|
import { TelemetryClient } from './telemetry-client.mjs';
|
|
10
14
|
import { jobIdentity } from './telemetry-schema.mjs';
|
|
11
15
|
|
|
@@ -27,6 +31,12 @@ const AUTONOMY_HARD_STOPS = Object.freeze(['authentication', 'mfa', 'captcha', '
|
|
|
27
31
|
const ATTENTION_STAGES = new Set(['discovery', 'assessment', 'application', 'contact', 'resume', 'questions', 'legal', 'demographic', 'review', 'submission', 'confirmation', 'outcome', 'answers', 'upload']);
|
|
28
32
|
const ATTENTION_BLOCKERS = new Set(['authentication', 'mfa', 'captcha', 'legal-attestation', 'demographic', 'government-id', 'ambiguous-authorization', 'ambiguous-compensation', 'unverifiable-claim', 'judgment', 'video', 'upload', 'site-error', 'other']);
|
|
29
33
|
const REQUIRED_ACTIONS = new Set(['sign-in', 'complete-mfa', 'complete-captcha', 'review-legal', 'choose-demographic', 'provide-government-id', 'provide-authorization', 'provide-compensation', 'verify-claim', 'provide-judgment', 'record-video', 'enable-upload', 'retry-site']);
|
|
34
|
+
const COMPANY_REAPPLY_COOLDOWN_DAYS = 15;
|
|
35
|
+
const COMPANY_REAPPLY_OVERRIDE = 'CANDIDATE APPROVED EARLY REAPPLICATION';
|
|
36
|
+
const SOURCE_CATALOG_URL = new URL('../references/SOURCES.json', import.meta.url);
|
|
37
|
+
const SOURCE_CATALOG = JSON.parse(readFileSync(SOURCE_CATALOG_URL, 'utf8'));
|
|
38
|
+
if (!Array.isArray(SOURCE_CATALOG)) throw new Error('The packaged source catalog is invalid.');
|
|
39
|
+
const SOURCE_CATALOG_IDS = new Set(SOURCE_CATALOG.map((source) => sourceId(source.id, 'source catalog id')));
|
|
30
40
|
const REQUIRED_PROFILE = ['name', 'email', 'phone', 'location', 'workAuthorization', 'roleFamilies', 'seniority', 'targetLocations', 'workModes', 'submissionMode', 'yearsExperience', 'autoSubmitMinScore', 'manualReviewMinScore', 'minMustHaveCoverage'];
|
|
31
41
|
const STRING_PROFILE_FIELDS = new Set(['name', 'email', 'phone', 'location', 'workAuthorization', 'linkedin', 'github', 'portfolio', 'availability', 'currentCompensation', 'targetCompensation', 'submissionMode']);
|
|
32
42
|
const ARRAY_PROFILE_FIELDS = new Set(['roleFamilies', 'seniority', 'skills', 'targetLocations', 'excludedLocations', 'workModes', 'industries', 'excludedCompanies']);
|
|
@@ -78,6 +88,7 @@ export function commandCategory([area, action]) {
|
|
|
78
88
|
if (area === 'telemetry') return 'telemetry';
|
|
79
89
|
if (area === 'autonomy') return 'profile';
|
|
80
90
|
if (area === 'round') return 'round';
|
|
91
|
+
if (area === 'sources') return 'search';
|
|
81
92
|
if (area === 'attention' || area === 'friction') return 'batch';
|
|
82
93
|
return 'other';
|
|
83
94
|
}
|
|
@@ -172,6 +183,12 @@ function stringArray(value, label, required = false) {
|
|
|
172
183
|
return value.map((item, index) => string(item, `${label}[${index}]`, 300));
|
|
173
184
|
}
|
|
174
185
|
|
|
186
|
+
function sourceId(value, label) {
|
|
187
|
+
const id = string(value, label, 80).toLowerCase();
|
|
188
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id)) throw new Error(`${label} must be a kebab-case public source ID.`);
|
|
189
|
+
return id;
|
|
190
|
+
}
|
|
191
|
+
|
|
175
192
|
function integer(value, label, min, max) {
|
|
176
193
|
if (!Number.isInteger(value) || value < min || value > max) throw new Error(`${label} must be an integer from ${min} to ${max}.`);
|
|
177
194
|
return value;
|
|
@@ -250,6 +267,9 @@ export function scoreJob(input, target) {
|
|
|
250
267
|
const description = string(job.description, 'job.description', 40000);
|
|
251
268
|
const source = string(job.source, 'job.source', 40).toLowerCase();
|
|
252
269
|
const discoverySource = job.discoverySource == null ? null : string(job.discoverySource, 'job.discoverySource', 40).toLowerCase();
|
|
270
|
+
if (job.discoverySourceId != null && !SOURCE_CATALOG_IDS.has(sourceId(job.discoverySourceId, 'job.discoverySourceId'))) {
|
|
271
|
+
throw new Error('job.discoverySourceId must match an ID in the packaged source catalog.');
|
|
272
|
+
}
|
|
253
273
|
const applicationChannel = job.applicationChannel == null ? null : string(job.applicationChannel, 'job.applicationChannel', 40).toLowerCase();
|
|
254
274
|
const eligibility = string(job.eligibility, 'job.eligibility', 40).toLowerCase();
|
|
255
275
|
const postingStatus = string(job.postingStatus ?? 'unclear', 'job.postingStatus', 40).toLowerCase();
|
|
@@ -407,6 +427,10 @@ export function validateLedgerEntry(input) {
|
|
|
407
427
|
};
|
|
408
428
|
if (entry.employerJobId != null) normalized.employerJobId = string(entry.employerJobId, 'entry.employerJobId', 300);
|
|
409
429
|
if (entry.discoverySource != null) normalized.discoverySource = string(entry.discoverySource, 'entry.discoverySource', 40).toLowerCase();
|
|
430
|
+
if (entry.discoverySourceId != null) {
|
|
431
|
+
normalized.discoverySourceId = sourceId(entry.discoverySourceId, 'entry.discoverySourceId');
|
|
432
|
+
if (!SOURCE_CATALOG_IDS.has(normalized.discoverySourceId)) throw new Error('entry.discoverySourceId must match an ID in the packaged source catalog.');
|
|
433
|
+
}
|
|
410
434
|
if (entry.applicationChannel != null) normalized.applicationChannel = string(entry.applicationChannel, 'entry.applicationChannel', 40).toLowerCase();
|
|
411
435
|
if (entry.roundId != null) normalized.roundId = string(entry.roundId, 'entry.roundId', 180);
|
|
412
436
|
if (!SOURCES.has(normalized.source)) throw new Error('entry.source is invalid.');
|
|
@@ -441,6 +465,27 @@ function normalizedText(value) {
|
|
|
441
465
|
return String(value ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
|
442
466
|
}
|
|
443
467
|
|
|
468
|
+
function comparableRoleTokens(value) {
|
|
469
|
+
const normalized = normalizedText(value)
|
|
470
|
+
.replace(/\bsr\b/g, 'senior')
|
|
471
|
+
.replace(/\bjr\b/g, 'junior')
|
|
472
|
+
.replace(/\bfull stack\b/g, 'fullstack')
|
|
473
|
+
.replace(/\bfront end\b/g, 'frontend')
|
|
474
|
+
.replace(/\bback end\b/g, 'backend');
|
|
475
|
+
const ignored = new Set(['software', 'junior', 'mid', 'senior', 'staff', 'principal', 'lead', 'manager', 'director', 'founding']);
|
|
476
|
+
return new Set(normalized.split(' ')
|
|
477
|
+
.map((token) => token === 'developer' ? 'engineer' : token)
|
|
478
|
+
.filter((token) => token && !ignored.has(token)));
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function rolesLikelySame(left, right) {
|
|
482
|
+
const leftTokens = comparableRoleTokens(left);
|
|
483
|
+
const rightTokens = comparableRoleTokens(right);
|
|
484
|
+
if (leftTokens.size === 0 || rightTokens.size === 0) return normalizedText(left) === normalizedText(right);
|
|
485
|
+
const shared = [...leftTokens].filter((token) => rightTokens.has(token)).length;
|
|
486
|
+
return shared / Math.min(leftTokens.size, rightTokens.size) >= 0.75;
|
|
487
|
+
}
|
|
488
|
+
|
|
444
489
|
function canonicalApplicationKey(entry, fallback = '') {
|
|
445
490
|
if (entry.employerJobId) return `job:${normalizedText(entry.company)}:${String(entry.employerJobId).toLowerCase()}`;
|
|
446
491
|
if (entry.company && entry.role) return `legacy-role:${normalizedText(entry.company)}:${normalizedText(entry.role)}`;
|
|
@@ -593,6 +638,91 @@ async function jsonLines(file) {
|
|
|
593
638
|
}
|
|
594
639
|
}
|
|
595
640
|
|
|
641
|
+
async function sourceCatalog() {
|
|
642
|
+
return SOURCE_CATALOG;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
async function sourcesList(filtersInput = {}, communitySources = []) {
|
|
646
|
+
const filters = object(filtersInput, 'source filters');
|
|
647
|
+
const allowed = new Set(['regions', 'roleFamilies', 'kinds', 'requiresSession']);
|
|
648
|
+
for (const key of Object.keys(filters)) if (!allowed.has(key)) throw new Error(`Unknown source filter: ${key}.`);
|
|
649
|
+
const regions = filters.regions == null ? [] : terms(stringArray(filters.regions, 'source filters.regions'));
|
|
650
|
+
const roleFamilies = filters.roleFamilies == null ? [] : terms(stringArray(filters.roleFamilies, 'source filters.roleFamilies'));
|
|
651
|
+
const kinds = filters.kinds == null ? [] : terms(stringArray(filters.kinds, 'source filters.kinds'));
|
|
652
|
+
if (filters.requiresSession != null && typeof filters.requiresSession !== 'boolean') throw new Error('source filters.requiresSession must be a Boolean.');
|
|
653
|
+
const community = communitySources.map((source) => ({
|
|
654
|
+
id: null,
|
|
655
|
+
communitySourceId: source.sourceId,
|
|
656
|
+
name: source.name,
|
|
657
|
+
kind: source.kind,
|
|
658
|
+
jobsUrl: source.baseUrl,
|
|
659
|
+
regions: source.regions,
|
|
660
|
+
roleFamilies: source.roleFamilies,
|
|
661
|
+
requiresSession: source.requiresSession,
|
|
662
|
+
access: 'community',
|
|
663
|
+
verification: 'direct-employer-or-ats',
|
|
664
|
+
registryStatus: source.registryStatus,
|
|
665
|
+
contributionCount: source.contributionCount,
|
|
666
|
+
}));
|
|
667
|
+
const sources = [...await sourceCatalog(), ...community].filter((source) => {
|
|
668
|
+
if (regions.length && !source.regions.some((value) => regions.includes(value))) return false;
|
|
669
|
+
if (roleFamilies.length && !source.roleFamilies.some((value) => roleFamilies.includes(value))) return false;
|
|
670
|
+
if (kinds.length && !kinds.includes(source.kind)) return false;
|
|
671
|
+
if (filters.requiresSession != null && source.requiresSession !== filters.requiresSession) return false;
|
|
672
|
+
return true;
|
|
673
|
+
});
|
|
674
|
+
return { version: 1, count: sources.length, sources };
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function sourceSuggestion(input) {
|
|
678
|
+
const value = normalizeCommunitySource(input);
|
|
679
|
+
return {
|
|
680
|
+
type: 'suggested',
|
|
681
|
+
id: `source-suggestion-${randomUUID()}`,
|
|
682
|
+
...value,
|
|
683
|
+
createdAt: new Date().toISOString(),
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function shareableSuggestion(suggestion) {
|
|
688
|
+
return Object.fromEntries(['name', 'baseUrl', 'kind', 'regions', 'roleFamilies', 'requiresSession'].map((key) => [key, suggestion[key]]));
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
async function markSourceShared(suggestionId, contribution) {
|
|
692
|
+
if (!contribution.shared) return;
|
|
693
|
+
await appendPrivateEvent('source-contribution-receipts', { suggestionId, sourceId: contribution.sourceId, sharedAt: new Date().toISOString() });
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
async function sourcesSuggest(input, community) {
|
|
697
|
+
const suggestion = sourceSuggestion(input);
|
|
698
|
+
await appendPrivateEvent('source-suggestions', suggestion);
|
|
699
|
+
const contribution = await community.contribute(shareableSuggestion(suggestion));
|
|
700
|
+
await markSourceShared(suggestion.id, contribution);
|
|
701
|
+
return { queued: true, community: contribution, suggestion };
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
async function sourcesPending() {
|
|
705
|
+
const suggestions = await jsonLines(join(await ensureStateDir(), 'source-suggestions.ndjson'));
|
|
706
|
+
const receipts = await jsonLines(join(await ensureStateDir(), 'source-contribution-receipts.ndjson'));
|
|
707
|
+
const shared = new Set(receipts.map((receipt) => receipt.suggestionId));
|
|
708
|
+
const pending = suggestions.filter((suggestion) => !shared.has(suggestion.id));
|
|
709
|
+
return { scope: 'local-unsent', count: pending.length, suggestions: pending };
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
export async function sourcesSync(community, limit = 10) {
|
|
713
|
+
const pending = (await sourcesPending()).suggestions.slice(0, limit);
|
|
714
|
+
let shared = 0;
|
|
715
|
+
let attempted = 0;
|
|
716
|
+
for (const suggestion of pending) {
|
|
717
|
+
attempted += 1;
|
|
718
|
+
const contribution = await community.contribute(shareableSuggestion(suggestion));
|
|
719
|
+
await markSourceShared(suggestion.id, contribution);
|
|
720
|
+
if (contribution.shared) shared += 1;
|
|
721
|
+
else if (contribution.reason === 'disabled' || contribution.reason === 'unavailable') break;
|
|
722
|
+
}
|
|
723
|
+
return { attempted, shared, remaining: (await sourcesPending()).count };
|
|
724
|
+
}
|
|
725
|
+
|
|
596
726
|
async function withStateLock(name, action) {
|
|
597
727
|
const dir = await ensureStateDir();
|
|
598
728
|
const lockPath = join(dir, `.${name}.lock`);
|
|
@@ -736,63 +866,105 @@ async function canonicalResumePath() {
|
|
|
736
866
|
return { path: target };
|
|
737
867
|
}
|
|
738
868
|
|
|
739
|
-
function duplicateResult(entries, candidate) {
|
|
869
|
+
function duplicateResult(entries, candidate, outcomes = [], now = new Date()) {
|
|
740
870
|
const candidateCompany = normalizedText(candidate.company);
|
|
741
871
|
const candidateRole = normalizedText(candidate.role);
|
|
742
872
|
const candidateUrl = normalizeUrl(candidate.url);
|
|
743
|
-
const sameCompanyRole = (entry) => candidateCompany && candidateRole
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
873
|
+
const sameCompanyRole = (entry) => candidateCompany && candidateRole
|
|
874
|
+
&& normalizedText(entry.company) === candidateCompany
|
|
875
|
+
&& rolesLikelySame(entry.role, candidate.role);
|
|
876
|
+
const hardId = entries.find((entry) => entry.id === candidate.id);
|
|
877
|
+
const hardEmployerJobId = entries.find((entry) => candidate.employerJobId && entry.employerJobId
|
|
878
|
+
&& normalizedText(entry.company) === candidateCompany
|
|
879
|
+
&& entry.employerJobId.toLowerCase() === String(candidate.employerJobId).toLowerCase());
|
|
880
|
+
const hardUrl = entries.find((entry) => normalizeUrl(entry.url) === candidateUrl);
|
|
881
|
+
const hard = hardId ?? hardEmployerJobId ?? hardUrl;
|
|
882
|
+
const hardReason = hardId ? 'id' : hardEmployerJobId ? 'employer-job-id' : hardUrl ? 'url' : null;
|
|
747
883
|
const possible = hard ? null : entries.find(sameCompanyRole);
|
|
748
884
|
const match = hard ?? possible;
|
|
749
|
-
const
|
|
750
|
-
? entries.filter((entry) => normalizedText(entry.company) === candidateCompany)
|
|
885
|
+
const sameCompanyEntries = candidateCompany
|
|
886
|
+
? entries.filter((entry) => normalizedText(entry.company) === candidateCompany)
|
|
887
|
+
: [];
|
|
888
|
+
const companyApplications = sameCompanyEntries.slice(-20).map((entry) => ({
|
|
751
889
|
id: entry.id,
|
|
752
890
|
company: entry.company,
|
|
753
891
|
role: entry.role,
|
|
754
892
|
submittedAt: entry.submittedAt,
|
|
755
893
|
...(entry.employerJobId ? { employerJobId: entry.employerJobId } : {}),
|
|
756
|
-
}))
|
|
757
|
-
|
|
894
|
+
}));
|
|
895
|
+
const latestCompanyApplication = [...sameCompanyEntries]
|
|
896
|
+
.filter((entry) => !Number.isNaN(Date.parse(entry.submittedAt)))
|
|
897
|
+
.sort((left, right) => Date.parse(right.submittedAt) - Date.parse(left.submittedAt))[0] ?? null;
|
|
898
|
+
const daysSinceLatest = latestCompanyApplication
|
|
899
|
+
? Math.max(0, Math.floor((now.getTime() - Date.parse(latestCompanyApplication.submittedAt)) / 86_400_000))
|
|
900
|
+
: null;
|
|
901
|
+
const hasFollowUp = latestCompanyApplication
|
|
902
|
+
? outcomes.some((outcome) => outcome.id === latestCompanyApplication.id)
|
|
903
|
+
: false;
|
|
904
|
+
let companyReapplyDecision = 'fresh-company';
|
|
905
|
+
if (hard) companyReapplyDecision = 'hard-duplicate';
|
|
906
|
+
else if (possible) companyReapplyDecision = 'same-role-review';
|
|
907
|
+
else if (latestCompanyApplication && hasFollowUp) companyReapplyDecision = 'follow-up-present';
|
|
908
|
+
else if (latestCompanyApplication && daysSinceLatest < COMPANY_REAPPLY_COOLDOWN_DAYS) companyReapplyDecision = 'cooldown-active';
|
|
909
|
+
else if (latestCompanyApplication) companyReapplyDecision = 'eligible-after-cooldown';
|
|
758
910
|
return {
|
|
759
911
|
duplicate: Boolean(hard),
|
|
760
912
|
possibleDuplicate: Boolean(possible),
|
|
761
|
-
reason:
|
|
913
|
+
reason: hardReason ?? (possible ? 'company-role' : null),
|
|
762
914
|
match: match ? { id: match.id, company: match.company, role: match.role, submittedAt: match.submittedAt } : null,
|
|
763
915
|
sameCompany: companyApplications.length > 0,
|
|
764
916
|
companyApplications,
|
|
917
|
+
companyReapply: {
|
|
918
|
+
eligible: companyReapplyDecision === 'eligible-after-cooldown',
|
|
919
|
+
decision: companyReapplyDecision,
|
|
920
|
+
cooldownDays: COMPANY_REAPPLY_COOLDOWN_DAYS,
|
|
921
|
+
latestSubmittedAt: latestCompanyApplication?.submittedAt ?? null,
|
|
922
|
+
daysSinceLatest,
|
|
923
|
+
hasFollowUp,
|
|
924
|
+
},
|
|
765
925
|
};
|
|
766
926
|
}
|
|
767
927
|
|
|
768
928
|
async function ledgerCheck(candidate) {
|
|
769
929
|
object(candidate, 'candidate');
|
|
770
|
-
const
|
|
930
|
+
const dir = await ensureStateDir();
|
|
931
|
+
const entries = await jsonLines(join(dir, 'applications.ndjson'));
|
|
932
|
+
const outcomes = await jsonLines(join(dir, 'outcomes.ndjson'));
|
|
771
933
|
string(candidate.url, 'candidate.url', 2048);
|
|
772
|
-
return duplicateResult(entries, candidate);
|
|
934
|
+
return duplicateResult(entries, candidate, outcomes);
|
|
773
935
|
}
|
|
774
936
|
|
|
775
|
-
async function ledgerAdd(entryInput, duplicateOverride) {
|
|
937
|
+
async function ledgerAdd(entryInput, duplicateOverride, companyReapplyOverride) {
|
|
776
938
|
const entry = validateLedgerEntry(entryInput);
|
|
777
939
|
return withStateLock('applications', async (dir) => {
|
|
778
940
|
const file = join(dir, 'applications.ndjson');
|
|
779
941
|
const entries = await jsonLines(file);
|
|
942
|
+
const outcomes = await jsonLines(join(dir, 'outcomes.ndjson'));
|
|
780
943
|
if (entry.roundId) {
|
|
781
944
|
const roundEvents = await jsonLines(join(dir, 'rounds.ndjson'));
|
|
782
945
|
if (!roundEvents.some((event) => event.type === 'started' && event.roundId === entry.roundId)) throw new Error('entry.roundId does not identify a started round.');
|
|
783
946
|
if (roundEvents.some((event) => event.type === 'completed' && event.roundId === entry.roundId)) throw new Error('entry.roundId identifies a completed round.');
|
|
784
947
|
}
|
|
785
|
-
const duplicate = duplicateResult(entries, entry);
|
|
786
|
-
if (duplicate.duplicate) throw new Error('
|
|
948
|
+
const duplicate = duplicateResult(entries, entry, outcomes);
|
|
949
|
+
if (duplicate.duplicate) throw new Error(`Hard duplicate blocked: matching ${duplicate.reason === 'employer-job-id' ? 'employer job ID or requisition' : duplicate.reason === 'url' ? 'canonical URL' : 'ledger ID'}.`);
|
|
787
950
|
if (duplicate.possibleDuplicate && duplicateOverride !== 'NEW REQUISITION CONFIRMED') throw new Error('A possible same-company role duplicate requires NEW REQUISITION CONFIRMED.');
|
|
788
|
-
|
|
951
|
+
if (!duplicate.possibleDuplicate && duplicate.companyReapply.decision === 'cooldown-active' && companyReapplyOverride !== COMPANY_REAPPLY_OVERRIDE) {
|
|
952
|
+
throw new Error(`Company reapplication cooldown is active for ${COMPANY_REAPPLY_COOLDOWN_DAYS} full days; use ${COMPANY_REAPPLY_OVERRIDE} only with explicit candidate approval.`);
|
|
953
|
+
}
|
|
954
|
+
if (!duplicate.possibleDuplicate && duplicate.companyReapply.decision === 'follow-up-present' && companyReapplyOverride !== COMPANY_REAPPLY_OVERRIDE) {
|
|
955
|
+
throw new Error(`Company reapplication blocked because the latest application has a recorded follow-up; use ${COMPANY_REAPPLY_OVERRIDE} only with explicit candidate approval.`);
|
|
956
|
+
}
|
|
957
|
+
const storedEntry = ['cooldown-active', 'follow-up-present'].includes(duplicate.companyReapply.decision)
|
|
958
|
+
? { ...entry, reapplicationApproval: 'candidate-explicit' }
|
|
959
|
+
: entry;
|
|
960
|
+
await appendFile(file, `${JSON.stringify(storedEntry)}\n`, { mode: 0o600 });
|
|
789
961
|
await chmod(file, 0o600);
|
|
790
962
|
if (entry.roundId) {
|
|
791
963
|
const roundsFile = join(dir, 'rounds.ndjson');
|
|
792
964
|
await appendFile(roundsFile, `${JSON.stringify({ type: 'submission-confirmed', roundId: entry.roundId, applicationId: entry.id, occurredAt: entry.submittedAt })}\n`, { mode: 0o600 });
|
|
793
965
|
await chmod(roundsFile, 0o600);
|
|
794
966
|
}
|
|
795
|
-
return { recorded:
|
|
967
|
+
return { recorded: storedEntry.id, review: buildReview([...entries, storedEntry]) };
|
|
796
968
|
});
|
|
797
969
|
}
|
|
798
970
|
|
|
@@ -1070,7 +1242,7 @@ function roundCompletedTelemetry(round) {
|
|
|
1070
1242
|
};
|
|
1071
1243
|
}
|
|
1072
1244
|
|
|
1073
|
-
async function executeCommand([area, action, value], telemetry, session) {
|
|
1245
|
+
async function executeCommand([area, action, value], telemetry, session, community) {
|
|
1074
1246
|
const domainEvents = [];
|
|
1075
1247
|
let result;
|
|
1076
1248
|
if (area === 'profile' && action === 'set' && value === '--stdin') {
|
|
@@ -1095,7 +1267,7 @@ async function executeCommand([area, action, value], telemetry, session) {
|
|
|
1095
1267
|
const input = await jsonStdin();
|
|
1096
1268
|
const telemetryDetails = validateSubmissionTelemetry(input.telemetry);
|
|
1097
1269
|
const entry = validateLedgerEntry(input);
|
|
1098
|
-
result = await ledgerAdd(entry, input.duplicateOverride);
|
|
1270
|
+
result = await ledgerAdd(entry, input.duplicateOverride, input.companyReapplyOverride);
|
|
1099
1271
|
domainEvents.push(await telemetryApplicationSubmitted(entry, telemetryDetails));
|
|
1100
1272
|
} else if (area === 'ledger' && action === 'outcome' && value === '--stdin') {
|
|
1101
1273
|
const outcome = await ledgerOutcome(await jsonStdin());
|
|
@@ -1116,12 +1288,24 @@ async function executeCommand([area, action, value], telemetry, session) {
|
|
|
1116
1288
|
else if (area === 'round' && action === 'complete' && value === '--stdin') {
|
|
1117
1289
|
result = await roundComplete(await jsonStdin());
|
|
1118
1290
|
domainEvents.push(roundCompletedTelemetry(result));
|
|
1119
|
-
} else if (area === '
|
|
1291
|
+
} else if (area === 'sources' && action === 'list' && value == null) {
|
|
1292
|
+
await sourcesSync(community);
|
|
1293
|
+
result = await sourcesList({}, await community.list());
|
|
1294
|
+
} else if (area === 'sources' && action === 'list' && value === '--stdin') {
|
|
1295
|
+
const filters = await jsonStdin();
|
|
1296
|
+
await sourcesSync(community);
|
|
1297
|
+
result = await sourcesList(filters, await community.list());
|
|
1298
|
+
}
|
|
1299
|
+
else if (area === 'sources' && action === 'suggest' && value === '--stdin') result = await sourcesSuggest(await jsonStdin(), community);
|
|
1300
|
+
else if (area === 'sources' && action === 'pending' && value == null) result = await sourcesPending();
|
|
1301
|
+
else if (area === 'sources' && action === 'sync' && value == null) result = await sourcesSync(community);
|
|
1302
|
+
else if (area === 'sources' && action === 'sharing' && ['status', 'enable', 'disable', 'reset'].includes(value)) result = await community.configure(value);
|
|
1303
|
+
else if (area === 'attention' && action === 'add' && value === '--stdin') result = await attentionAdd(await jsonStdin());
|
|
1120
1304
|
else if (area === 'attention' && action === 'list' && value == null) result = await attentionList();
|
|
1121
1305
|
else if (area === 'attention' && action === 'resolve' && value === '--stdin') result = await attentionResolve(await jsonStdin());
|
|
1122
1306
|
else if (area === 'friction' && action === 'record' && value === '--stdin') result = await frictionRecord(await jsonStdin());
|
|
1123
1307
|
else if (area === 'friction' && action === 'list' && value == null) result = await frictionList();
|
|
1124
|
-
else throw new Error('Usage: profile set|migrate --stdin; profile check|field <name>; resume import <url-or-pdf>|path; score --stdin; ledger check|add|outcome|review-ack --stdin; ledger review; autonomy grant --stdin|status|preview|revoke; round start|complete --stdin|status [round-id]; attention add|resolve --stdin|list; friction record --stdin|list; telemetry status|enable|disable|reset|preview --stdin|record --stdin');
|
|
1308
|
+
else throw new Error('Usage: profile set|migrate --stdin; profile check|field <name>; resume import <url-or-pdf>|path; score --stdin; ledger check|add|outcome|review-ack --stdin; ledger review; autonomy grant --stdin|status|preview|revoke; round start|complete --stdin|status [round-id]; sources list [--stdin]|suggest --stdin|pending|sync|sharing status|enable|disable|reset; attention add|resolve --stdin|list; friction record --stdin|list; telemetry status|enable|disable|reset|preview --stdin|record --stdin');
|
|
1125
1309
|
for (const event of domainEvents) await telemetry.record(event, session);
|
|
1126
1310
|
return result;
|
|
1127
1311
|
}
|
|
@@ -1143,6 +1327,7 @@ async function recordInstallationStart(telemetry, session) {
|
|
|
1143
1327
|
async function main(args) {
|
|
1144
1328
|
const [area, action, value] = args;
|
|
1145
1329
|
const telemetry = new TelemetryClient({ stateDir: stateDir() });
|
|
1330
|
+
const community = new SourceCommunityClient({ stateDir: stateDir() });
|
|
1146
1331
|
if (area === 'telemetry') {
|
|
1147
1332
|
if (['status', 'enable', 'disable', 'reset'].includes(action) && value == null) return print(await telemetry.configure(action));
|
|
1148
1333
|
if (action === 'preview' && value === '--stdin') return print(await telemetry.preview(await jsonStdin()));
|
|
@@ -1159,7 +1344,7 @@ async function main(args) {
|
|
|
1159
1344
|
await recordInstallationStart(telemetry, session);
|
|
1160
1345
|
const started = Date.now();
|
|
1161
1346
|
try {
|
|
1162
|
-
const result = await executeCommand(args, telemetry, session);
|
|
1347
|
+
const result = await executeCommand(args, telemetry, session, community);
|
|
1163
1348
|
await telemetry.record({ event: 'command_completed', properties: { command, result: 'success', durationBucket: durationBucket(Date.now() - started) } }, session);
|
|
1164
1349
|
return print(result);
|
|
1165
1350
|
} catch (error) {
|
|
@@ -1169,7 +1354,7 @@ async function main(args) {
|
|
|
1169
1354
|
}
|
|
1170
1355
|
}
|
|
1171
1356
|
|
|
1172
|
-
if (import.meta.url ===
|
|
1357
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
1173
1358
|
main(process.argv.slice(2)).catch((error) => {
|
|
1174
1359
|
process.stderr.write(`Error: ${error.message}\n`);
|
|
1175
1360
|
process.exitCode = 1;
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { chmod, mkdir, open, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
4
|
+
|
|
5
|
+
import { SKILL_VERSION } from './telemetry-client.mjs';
|
|
6
|
+
import { createSourceContributionEnvelope, normalizeCommunitySource, validateCommunitySourceList } from './source-community-schema.mjs';
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_SOURCE_COMMUNITY_ENDPOINT = process.env.JOB_APPLICATION_AGENT_SOURCE_COMMUNITY_URL ?? process.env.JOB_APPLICATION_AGENT_TELEMETRY_URL ?? 'https://job-application-agent-telemetry.varora1406.workers.dev';
|
|
9
|
+
export const SOURCE_SHARING_NOTICE = 'Community source sharing is enabled by default. Repeatable public job boards and hiring feeds are shared anonymously into a pending maintainer-review queue after removing personal and referral data. Run `sources sharing disable` to opt out.\n';
|
|
10
|
+
|
|
11
|
+
const CONFIG_FILE = 'source-sharing.json';
|
|
12
|
+
const CONFIG_LOCK_FILE = '.source-sharing.lock';
|
|
13
|
+
const CONFIG_LOCK_TIMEOUT_MS = 15_000;
|
|
14
|
+
const CONFIG_LOCK_STALE_MS = 60_000;
|
|
15
|
+
|
|
16
|
+
function defaultConfig() {
|
|
17
|
+
return { version: 1, enabled: true, disclosed: false, installationId: null, token: null, tokenExpiresAt: null };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function sameCredentialState(left, right) {
|
|
21
|
+
return left.enabled === right.enabled
|
|
22
|
+
&& left.installationId === right.installationId
|
|
23
|
+
&& left.token === right.token
|
|
24
|
+
&& left.tokenExpiresAt === right.tokenExpiresAt;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function processIsAlive(pid) {
|
|
28
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
29
|
+
if (pid === process.pid) return true;
|
|
30
|
+
try {
|
|
31
|
+
process.kill(pid, 0);
|
|
32
|
+
return true;
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (error.code === 'ESRCH') return false;
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function writePrivate(file, value) {
|
|
40
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
41
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
42
|
+
await rename(temporary, file);
|
|
43
|
+
await chmod(file, 0o600);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class SourceCommunityClient {
|
|
47
|
+
constructor({ stateDir, endpoint = DEFAULT_SOURCE_COMMUNITY_ENDPOINT, fetch: fetchFn = globalThis.fetch, stderr = (value) => process.stderr.write(value), now = () => new Date(), timeoutMs = Number(process.env.JOB_APPLICATION_AGENT_SOURCE_COMMUNITY_TIMEOUT_MS ?? 3000) }) {
|
|
48
|
+
this.stateDir = stateDir;
|
|
49
|
+
this.endpoint = endpoint.replace(/\/$/, '');
|
|
50
|
+
this.fetch = fetchFn;
|
|
51
|
+
this.stderr = stderr;
|
|
52
|
+
this.now = now;
|
|
53
|
+
this.timeoutMs = timeoutMs;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
get configPath() { return join(this.stateDir, CONFIG_FILE); }
|
|
57
|
+
get configLockPath() { return join(this.stateDir, CONFIG_LOCK_FILE); }
|
|
58
|
+
|
|
59
|
+
async ensureDirectory() {
|
|
60
|
+
await mkdir(this.stateDir, { recursive: true, mode: 0o700 });
|
|
61
|
+
await chmod(this.stateDir, 0o700);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async readConfig() {
|
|
65
|
+
try {
|
|
66
|
+
const value = JSON.parse(await readFile(this.configPath, 'utf8'));
|
|
67
|
+
return { version: 1, enabled: value.enabled !== false, disclosed: value.disclosed === true, installationId: value.installationId ?? null, token: value.token ?? null, tokenExpiresAt: value.tokenExpiresAt ?? null };
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (error.code === 'ENOENT') return null;
|
|
70
|
+
return { version: 1, enabled: false, disclosed: true, installationId: null, token: null, tokenExpiresAt: null };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async saveConfigUnlocked(config) {
|
|
75
|
+
await this.ensureDirectory();
|
|
76
|
+
await writePrivate(this.configPath, config);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async removeStaleConfigLock() {
|
|
80
|
+
let contents;
|
|
81
|
+
let metadata;
|
|
82
|
+
try {
|
|
83
|
+
[contents, metadata] = await Promise.all([
|
|
84
|
+
readFile(this.configLockPath, 'utf8'),
|
|
85
|
+
stat(this.configLockPath),
|
|
86
|
+
]);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (error.code === 'ENOENT') return true;
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
const trimmed = contents.trim();
|
|
92
|
+
const pid = /^\d+$/.test(trimmed) ? Number(trimmed) : null;
|
|
93
|
+
const ownerIsDead = pid !== null && !processIsAlive(pid);
|
|
94
|
+
const lockExpired = Date.now() - metadata.mtimeMs >= CONFIG_LOCK_STALE_MS;
|
|
95
|
+
if (!ownerIsDead && !lockExpired) return false;
|
|
96
|
+
try {
|
|
97
|
+
if (await readFile(this.configLockPath, 'utf8') !== contents) return false;
|
|
98
|
+
await unlink(this.configLockPath);
|
|
99
|
+
return true;
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (error.code === 'ENOENT') return true;
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async withConfigLock(operation) {
|
|
107
|
+
await this.ensureDirectory();
|
|
108
|
+
const startedAt = Date.now();
|
|
109
|
+
let handle;
|
|
110
|
+
while (!handle) {
|
|
111
|
+
try {
|
|
112
|
+
handle = await open(this.configLockPath, 'wx', 0o600);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (error.code !== 'EEXIST') throw error;
|
|
115
|
+
if (await this.removeStaleConfigLock()) continue;
|
|
116
|
+
if (Date.now() - startedAt >= CONFIG_LOCK_TIMEOUT_MS) throw new Error('Could not acquire source-sharing config lock.');
|
|
117
|
+
await delay(20);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
await handle.writeFile(`${process.pid}\n`);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
await handle.close();
|
|
124
|
+
await unlink(this.configLockPath).catch(() => {});
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
return await operation();
|
|
129
|
+
} finally {
|
|
130
|
+
await handle.close();
|
|
131
|
+
await unlink(this.configLockPath).catch((error) => { if (error.code !== 'ENOENT') throw error; });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async updateConfig(transform) {
|
|
136
|
+
return this.withConfigLock(async () => {
|
|
137
|
+
const current = await this.readConfig() ?? defaultConfig();
|
|
138
|
+
const next = transform(current);
|
|
139
|
+
await this.saveConfigUnlocked(next);
|
|
140
|
+
return next;
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async config() {
|
|
145
|
+
return this.withConfigLock(async () => {
|
|
146
|
+
const existing = await this.readConfig();
|
|
147
|
+
if (existing) return existing;
|
|
148
|
+
const config = defaultConfig();
|
|
149
|
+
await this.saveConfigUnlocked(config);
|
|
150
|
+
return config;
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async status() {
|
|
155
|
+
const config = await this.readConfig();
|
|
156
|
+
return { enabled: config?.enabled ?? true, disclosed: config?.disclosed ?? false, hasInstallationId: Boolean(config?.installationId), endpoint: this.endpoint, schemaVersion: 1 };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async configure(action) {
|
|
160
|
+
if (action === 'status') return this.status();
|
|
161
|
+
await this.updateConfig((config) => {
|
|
162
|
+
if (action === 'enable') config.enabled = true;
|
|
163
|
+
else if (action === 'disable') config.enabled = false;
|
|
164
|
+
else if (action === 'reset') Object.assign(config, { enabled: false, disclosed: true, installationId: null, token: null, tokenExpiresAt: null });
|
|
165
|
+
else throw new Error('Source sharing action must be status, enable, disable, or reset.');
|
|
166
|
+
config.disclosed = true;
|
|
167
|
+
return config;
|
|
168
|
+
});
|
|
169
|
+
return this.status();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async credentials(config) {
|
|
173
|
+
if (config.installationId && config.token && config.tokenExpiresAt && Date.parse(config.tokenExpiresAt) > this.now().getTime() + 60_000) return config;
|
|
174
|
+
let expectedState = config;
|
|
175
|
+
let body = config.installationId && config.token ? { installationId: config.installationId, token: config.token } : {};
|
|
176
|
+
let response = await this.fetch(`${this.endpoint}/v1/install`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(this.timeoutMs) });
|
|
177
|
+
if (response.status === 401 && body.installationId) {
|
|
178
|
+
config = await this.updateConfig((current) => {
|
|
179
|
+
if (current.installationId === body.installationId) Object.assign(current, { installationId: null, token: null, tokenExpiresAt: null });
|
|
180
|
+
return current;
|
|
181
|
+
});
|
|
182
|
+
expectedState = config;
|
|
183
|
+
body = {};
|
|
184
|
+
response = await this.fetch(`${this.endpoint}/v1/install`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(this.timeoutMs) });
|
|
185
|
+
}
|
|
186
|
+
if (!response.ok) throw new Error('community relay unavailable');
|
|
187
|
+
const identity = await response.json();
|
|
188
|
+
return this.updateConfig((current) => sameCredentialState(current, expectedState)
|
|
189
|
+
? { ...current, installationId: identity.installationId, token: identity.token, tokenExpiresAt: identity.expiresAt }
|
|
190
|
+
: current);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async preview(input) {
|
|
194
|
+
return normalizeCommunitySource(input);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async contribute(input) {
|
|
198
|
+
const source = normalizeCommunitySource(input);
|
|
199
|
+
try {
|
|
200
|
+
let config = await this.config();
|
|
201
|
+
if (!config.enabled) return { shared: false, reason: 'disabled' };
|
|
202
|
+
if (!config.disclosed) {
|
|
203
|
+
this.stderr(SOURCE_SHARING_NOTICE);
|
|
204
|
+
config = await this.updateConfig((current) => ({ ...current, disclosed: true }));
|
|
205
|
+
if (!config.enabled) return { shared: false, reason: 'disabled' };
|
|
206
|
+
}
|
|
207
|
+
config = await this.credentials(config);
|
|
208
|
+
let sent = await this.sendContribution(config, source);
|
|
209
|
+
if (sent.disabled) return { shared: false, reason: 'disabled' };
|
|
210
|
+
let response = sent.response;
|
|
211
|
+
if (response.status === 401) {
|
|
212
|
+
config = await this.updateConfig((current) => ({ ...current, tokenExpiresAt: null }));
|
|
213
|
+
config = await this.credentials(config);
|
|
214
|
+
sent = await this.sendContribution(config, source);
|
|
215
|
+
if (sent.disabled) return { shared: false, reason: 'disabled' };
|
|
216
|
+
response = sent.response;
|
|
217
|
+
}
|
|
218
|
+
if (!response.ok) {
|
|
219
|
+
return { shared: false, reason: 'unavailable' };
|
|
220
|
+
}
|
|
221
|
+
const result = await response.json();
|
|
222
|
+
const allowed = new Set(['accepted', 'sourceId', 'publicationStatus', 'uniqueContributors']);
|
|
223
|
+
if (!result || typeof result !== 'object' || Array.isArray(result) || Object.keys(result).some((key) => !allowed.has(key))) return { shared: false, reason: 'unavailable' };
|
|
224
|
+
if (result.accepted !== true || !/^community-[0-9a-f]{16}$/.test(result.sourceId)) return { shared: false, reason: 'unavailable' };
|
|
225
|
+
if (!['pending', 'published', 'rejected'].includes(result.publicationStatus)) return { shared: false, reason: 'unavailable' };
|
|
226
|
+
if (!Number.isSafeInteger(result.uniqueContributors) || result.uniqueContributors < 1 || result.uniqueContributors > 1_000_000_000) return { shared: false, reason: 'unavailable' };
|
|
227
|
+
return { shared: true, sourceId: result.sourceId, publicationStatus: result.publicationStatus, uniqueContributors: result.uniqueContributors };
|
|
228
|
+
} catch {
|
|
229
|
+
return { shared: false, reason: 'unavailable' };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async sendContribution(config, source) {
|
|
234
|
+
return this.withConfigLock(async () => {
|
|
235
|
+
const current = await this.readConfig() ?? config;
|
|
236
|
+
if (!current.enabled) return { disabled: true };
|
|
237
|
+
const envelope = createSourceContributionEnvelope({ installationId: current.installationId, token: current.token, source, skillVersion: SKILL_VERSION });
|
|
238
|
+
const response = await this.fetch(`${this.endpoint}/v1/sources`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(envelope), signal: AbortSignal.timeout(this.timeoutMs) });
|
|
239
|
+
return { disabled: false, response };
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async list() {
|
|
244
|
+
if (this.readUnavailable) return [];
|
|
245
|
+
try {
|
|
246
|
+
const response = await this.fetch(`${this.endpoint}/v1/sources`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(this.timeoutMs) });
|
|
247
|
+
if (!response.ok) return [];
|
|
248
|
+
return validateCommunitySourceList(await response.json());
|
|
249
|
+
} catch {
|
|
250
|
+
this.readUnavailable = true;
|
|
251
|
+
return [];
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|